Translate remaining Go source and overlay.html comments to English

Identifiers, comments, and log/console messages in auth.go, i18n.go,
counter.go, totals.go, and overlay.html's script/CSS comments — the
files the TLS commit didn't already touch. No functional changes.

locales/*.json and overlay.html's own FALLBACK/data-i18n strings stay
bilingual on purpose: that's end-user text for the overlay/panel, a
different audience than the source code, served by the existing i18n
system rather than this convention.
This commit is contained in:
emmatherock committed 2026-09-17 21:51:33 -03:00
1 parent e9fe10f0f7
commit 5a2c3272e9
7 files changed
+213 -211

No files matched your search

+31 -31
View File
@@ -1,20 +1,19 @@
// auth.go: autenticacion entre el hub y los peers. // auth.go: authentication between the hub and its peers.
// //
// El transporte NO se asume confiable. Puede ser Tailscale, ZeroTier, // The transport is NOT assumed trustworthy. It could be Tailscale,
// WireGuard, o un puerto abierto directo a internet: eso lo decide quien // ZeroTier, WireGuard, or a port opened straight to the internet: that's
// lo usa, no nosotros. Asi que la conexion se defiende sola. // up to whoever runs it, not us. So the connection defends itself.
// //
// El token es OBLIGATORIO y lo genera el programa (nadie elige "1234"), y // The token is REQUIRED and generated by the program (nobody gets to pick
// nunca viaja por la red: el hub manda un desafio al azar y el peer // "1234"), and it never travels over the network: the hub sends a random
// responde con un HMAC del desafio usando el token como clave. Quien // challenge and the peer replies with an HMAC of it, keyed by the token.
// escuche el trafico no se lleva el token, y no puede repetir una // Anyone listening to the traffic doesn't get the token, and can't replay
// respuesta vieja porque el desafio cambia en cada conexion. // an old response because the challenge changes on every connection.
// //
// Lo que esto NO hace: cifrar. Los mensajes (nombre y numero de muertes) // This proves the PEER's identity to the hub. It doesn't encrypt
// viajan en claro. Para un contador de muertes en un stream es // anything by itself — that's TLS's job now, one layer up (see
// aceptable, pero si te importa, poné la conexion adentro de una VPN o // tlscert.go/pin.go), which in turn proves the HUB's identity to the
// un tunel con TLS. Lo que el token garantiza es que nadie pueda // peer via certificate pinning. Neither layer replaces the other.
// INYECTAR datos falsos en tu overlay.
package main package main
import ( import (
@@ -33,11 +32,11 @@ import (
const ( const (
tokenBytes = 16 // 128 bits tokenBytes = 16 // 128 bits
nonceBytes = 16 nonceBytes = 16
// Plazo para autenticarse. Una conexion que se queda callada tras el // Deadline to authenticate. A connection that goes quiet after the
// desafio se cierra en vez de ocupar un lugar para siempre. // challenge gets closed instead of holding a slot forever.
authTimeoutSeconds = 10 authTimeoutSeconds = 10
// Techo de conexiones simultaneas: evita que alguien deje el puerto // Cap on simultaneous connections: keeps someone from starving the
// sin recursos abriendo sockets. // port of resources by opening sockets.
maxPeerConns = 8 maxPeerConns = 8
) )
@@ -56,12 +55,13 @@ func randomHex(n int) (string, error) {
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }
// resolveToken decide el token efectivo. Si el config trae uno, manda ese // resolveToken decides the effective token. If the config has one, that
// (para quien quiera elegirlo a mano o compartir uno ya acordado). Si no, // wins (for anyone who wants to pick it by hand or share a pre-agreed
// se usa el de token.txt, y si tampoco existe se genera uno y se guarda. // one). Otherwise it uses the one in token.txt, generating and saving one
// if that doesn't exist either.
// //
// Devuelve tambien si lo acaba de generar, para avisarlo fuerte en la // Also returns whether it was just generated, so it can be announced
// consola: es lo que hay que pasarle al compañero. // loudly on the console: that's what needs to be handed to your partner.
func resolveToken(cfg config) (token string, generated bool, err error) { func resolveToken(cfg config) (token string, generated bool, err error) {
if t := strings.TrimSpace(cfg.Token); t != "" { if t := strings.TrimSpace(cfg.Token); t != "" {
return t, false, nil return t, false, nil
@@ -74,23 +74,23 @@ func resolveToken(cfg config) (token string, generated bool, err error) {
} }
t, gerr := randomHex(tokenBytes) t, gerr := randomHex(tokenBytes)
if gerr != nil { if gerr != nil {
return "", false, fmt.Errorf("no pude generar un token: %w", gerr) return "", false, fmt.Errorf("couldn't generate a token: %w", gerr)
} }
if werr := os.WriteFile(path, []byte(t), 0600); werr != nil { if werr := os.WriteFile(path, []byte(t), 0600); werr != nil {
return "", false, fmt.Errorf("no pude guardar token.txt: %w", werr) return "", false, fmt.Errorf("couldn't save token.txt: %w", werr)
} }
return t, true, nil return t, true, nil
} }
// proofFor calcula la respuesta al desafio: HMAC-SHA256(token, nonce). // proofFor computes the answer to the challenge: HMAC-SHA256(token, nonce).
func proofFor(token, nonce string) string { func proofFor(token, nonce string) string {
m := hmac.New(sha256.New, []byte(token)) m := hmac.New(sha256.New, []byte(token))
m.Write([]byte(nonce)) m.Write([]byte(nonce))
return hex.EncodeToString(m.Sum(nil)) return hex.EncodeToString(m.Sum(nil))
} }
// proofValid compara en tiempo constante. Con == la comparacion corta en // proofValid compares in constant time. With == the comparison short-
// el primer byte distinto, y eso filtra informacion por tiempos. // circuits at the first differing byte, which leaks information via timing.
func proofValid(token, nonce, got string) bool { func proofValid(token, nonce, got string) bool {
want := proofFor(token, nonce) want := proofFor(token, nonce)
return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
@@ -98,8 +98,8 @@ func proofValid(token, nonce, got string) bool {
func logTokenBanner(token string, generated bool) { func logTokenBanner(token string, generated bool) {
if generated { if generated {
log.Printf("generé un token nuevo y lo guardé en token.txt") log.Printf("generated a new token and saved it to token.txt")
} }
log.Printf("token de conexion: %s", token) log.Printf("connection token: %s", token)
log.Printf("tu compañero tiene que poner ESE token en su config.toml; sin el, su conexion se rechaza") log.Printf("your partner needs to put THAT exact token in their config.toml; without it, their connection gets rejected")
} }
+50 -48
View File
@@ -8,8 +8,8 @@ import (
"time" "time"
) )
// arrancaHub levanta un hub con un token dado y devuelve su direccion. // startHub spins up a hub with a given token and returns its address.
func arrancaHub(t *testing.T, token string) (*peerRegistry, string) { func startHub(t *testing.T, token string) (*peerRegistry, string) {
t.Helper() t.Helper()
reg := newPeerRegistry() reg := newPeerRegistry()
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -26,92 +26,94 @@ func arrancaHub(t *testing.T, token string) (*peerRegistry, string) {
func peers(reg *peerRegistry) int { return len(reg.views()) } func peers(reg *peerRegistry) int { return len(reg.views()) }
// El token correcto entra y sus datos llegan. // The correct token gets in and its data arrives.
func TestTokenCorrectoEntra(t *testing.T) { func TestCorrectTokenGetsIn(t *testing.T) {
reg, addr := arrancaHub(t, "el-bueno") reg, addr := startHub(t, "the-right-one")
c, err := wsDial(addr, "/ws", 3*time.Second) c, err := wsDial(addr, "/ws", 3*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
if err := authenticate(c, config{Token: "el-bueno", clientID: "abc"}); err != nil { if err := authenticate(c, config{Token: "the-right-one", clientID: "abc"}); err != nil {
t.Fatalf("deberia haber autenticado: %v", err) t.Fatalf("should have authenticated: %v", err)
} }
msg, _ := json.Marshal(peerMessage{Type: "state", ID: "abc", Name: "Fulano", Deaths: 7}) msg, _ := json.Marshal(peerMessage{Type: "state", ID: "abc", Name: "Somebody", Deaths: 7})
if err := c.WriteText(msg); err != nil { if err := c.WriteText(msg); err != nil {
t.Fatal(err) t.Fatal(err)
} }
esperarHasta(t, func() bool { return peers(reg) == 1 }, "el peer nunca aparecio") waitUntil(t, func() bool { return peers(reg) == 1 }, "the peer never showed up")
if got := reg.views()[0].Deaths; got != 7 { if got := reg.views()[0].Deaths; got != 7 {
t.Fatalf("esperaba 7 muertes, dio %d", got) t.Fatalf("expected 7 deaths, got %d", got)
} }
} }
// El token equivocado NO entra y no deja rastro. // The wrong token does NOT get in, and leaves no trace.
func TestTokenEquivocadoNoEntra(t *testing.T) { func TestWrongTokenDoesNotGetIn(t *testing.T) {
reg, addr := arrancaHub(t, "el-bueno") reg, addr := startHub(t, "the-right-one")
c, err := wsDial(addr, "/ws", 3*time.Second) c, err := wsDial(addr, "/ws", 3*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
_ = authenticate(c, config{Token: "el-malo", clientID: "x"}) _ = authenticate(c, config{Token: "the-wrong-one", clientID: "x"})
msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruso", Deaths: 999}) msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruder", Deaths: 999})
c.WriteText(msg) c.WriteText(msg)
time.Sleep(700 * time.Millisecond) time.Sleep(700 * time.Millisecond)
if peers(reg) != 0 { if peers(reg) != 0 {
t.Fatalf("el hub acepto datos con el token equivocado: %+v", reg.views()) t.Fatalf("the hub accepted data with the wrong token: %+v", reg.views())
} }
} }
// Mandar datos SIN autenticarse tampoco funciona. // Sending data WITHOUT authenticating doesn't work either.
func TestSinAutenticarNoEntra(t *testing.T) { func TestUnauthenticatedDoesNotGetIn(t *testing.T) {
reg, addr := arrancaHub(t, "el-bueno") reg, addr := startHub(t, "the-right-one")
c, err := wsDial(addr, "/ws", 3*time.Second) c, err := wsDial(addr, "/ws", 3*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruso", Deaths: 999}) msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruder", Deaths: 999})
c.WriteText(msg) c.WriteText(msg)
time.Sleep(700 * time.Millisecond) time.Sleep(700 * time.Millisecond)
if peers(reg) != 0 { if peers(reg) != 0 {
t.Fatalf("el hub acepto datos sin autenticacion: %+v", reg.views()) t.Fatalf("the hub accepted data with no authentication: %+v", reg.views())
} }
} }
// El token no viaja: lo unico que sale del peer es un HMAC del desafio. // The token never travels: the only thing that leaves the peer is an
func TestElTokenNoViajaPorLaRed(t *testing.T) { // HMAC of the challenge.
_, addr := arrancaHub(t, "secreto-muy-secreto") func TestTheTokenNeverTravelsOverTheWire(t *testing.T) {
_, addr := startHub(t, "very-secret-secret")
c, err := wsDial(addr, "/ws", 3*time.Second) c, err := wsDial(addr, "/ws", 3*time.Second)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
raw, err := c.ReadMessage() // el desafio raw, err := c.ReadMessage() // the challenge
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var ch authChallenge var ch authChallenge
json.Unmarshal(raw, &ch) json.Unmarshal(raw, &ch)
if ch.Nonce == "" { if ch.Nonce == "" {
t.Fatal("el hub no mando desafio") t.Fatal("the hub didn't send a challenge")
} }
reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: proofFor("secreto-muy-secreto", ch.Nonce)}) reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: proofFor("very-secret-secret", ch.Nonce)})
if string(reply) == "" { if string(reply) == "" {
t.Fatal("respuesta vacia") t.Fatal("empty reply")
} }
for _, b := range [][]byte{raw, reply} { for _, b := range [][]byte{raw, reply} {
if contiene(string(b), "secreto-muy-secreto") { if contains(string(b), "very-secret-secret") {
t.Fatalf("el token aparece en el trafico: %s", b) t.Fatalf("the token shows up in the traffic: %s", b)
} }
} }
} }
// Una respuesta capturada no sirve en otra conexion: el desafio cambia. // A captured response can't be replayed on another connection: the
func TestNoSePuedeRepetirUnaRespuestaVieja(t *testing.T) { // challenge changes.
reg, addr := arrancaHub(t, "el-bueno") func TestAnOldResponseCannotBeReplayed(t *testing.T) {
reg, addr := startHub(t, "the-right-one")
c1, err := wsDial(addr, "/ws", 3*time.Second) c1, err := wsDial(addr, "/ws", 3*time.Second)
if err != nil { if err != nil {
@@ -120,7 +122,7 @@ func TestNoSePuedeRepetirUnaRespuestaVieja(t *testing.T) {
raw, _ := c1.ReadMessage() raw, _ := c1.ReadMessage()
var ch1 authChallenge var ch1 authChallenge
json.Unmarshal(raw, &ch1) json.Unmarshal(raw, &ch1)
proofViejo := proofFor("el-bueno", ch1.Nonce) oldProof := proofFor("the-right-one", ch1.Nonce)
c1.Close() c1.Close()
c2, err := wsDial(addr, "/ws", 3*time.Second) c2, err := wsDial(addr, "/ws", 3*time.Second)
@@ -132,43 +134,43 @@ func TestNoSePuedeRepetirUnaRespuestaVieja(t *testing.T) {
var ch2 authChallenge var ch2 authChallenge
json.Unmarshal(raw2, &ch2) json.Unmarshal(raw2, &ch2)
if ch2.Nonce == ch1.Nonce { if ch2.Nonce == ch1.Nonce {
t.Fatal("el desafio se repitio entre conexiones: el nonce tiene que ser distinto") t.Fatal("the challenge repeated across connections: the nonce has to differ")
} }
reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: proofViejo}) reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: oldProof})
c2.WriteText(reply) c2.WriteText(reply)
msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Repetidor", Deaths: 1}) msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Replayer", Deaths: 1})
c2.WriteText(msg) c2.WriteText(msg)
time.Sleep(700 * time.Millisecond) time.Sleep(700 * time.Millisecond)
if peers(reg) != 0 { if peers(reg) != 0 {
t.Fatalf("el hub acepto una respuesta repetida: %+v", reg.views()) t.Fatalf("the hub accepted a replayed response: %+v", reg.views())
} }
} }
// El token generado es aleatorio y de largo razonable. // The generated token is random and of a reasonable length.
func TestTokenGeneradoEsFuerte(t *testing.T) { func TestGeneratedTokenIsStrong(t *testing.T) {
testExeDir = t.TempDir() testExeDir = t.TempDir()
a, gen, err := resolveToken(config{}) a, gen, err := resolveToken(config{})
if err != nil || !gen { if err != nil || !gen {
t.Fatalf("deberia haber generado uno: gen=%v err=%v", gen, err) t.Fatalf("should have generated one: gen=%v err=%v", gen, err)
} }
if len(a) != tokenBytes*2 { if len(a) != tokenBytes*2 {
t.Fatalf("largo inesperado: %d", len(a)) t.Fatalf("unexpected length: %d", len(a))
} }
// la segunda vez lo lee del archivo, no genera otro // the second time it reads it from the file, doesn't generate another
b, gen2, _ := resolveToken(config{}) b, gen2, _ := resolveToken(config{})
if gen2 || a != b { if gen2 || a != b {
t.Fatalf("deberia reusar el guardado: %q vs %q (gen=%v)", a, b, gen2) t.Fatalf("should reuse the saved one: %q vs %q (gen=%v)", a, b, gen2)
} }
// y en otra instalacion sale distinto // and a different installation gets a different one
testExeDir = t.TempDir() testExeDir = t.TempDir()
c, _, _ := resolveToken(config{}) c, _, _ := resolveToken(config{})
if c == a { if c == a {
t.Fatal("dos instalaciones generaron el mismo token") t.Fatal("two installations generated the same token")
} }
testExeDir = "" testExeDir = ""
} }
func contiene(s, sub string) bool { func contains(s, sub string) bool {
return len(sub) > 0 && len(s) >= len(sub) && (func() bool { return len(sub) > 0 && len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ { for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub { if s[i:i+len(sub)] == sub {
@@ -179,7 +181,7 @@ func contiene(s, sub string) bool {
})() })()
} }
func esperarHasta(t *testing.T, cond func() bool, msg string) { func waitUntil(t *testing.T, cond func() bool, msg string) {
t.Helper() t.Helper()
deadline := time.Now().Add(4 * time.Second) deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
+33 -35
View File
@@ -1,11 +1,10 @@
// counter.go: la contabilidad de muertes. // counter.go: death accounting.
// //
// A proposito NO tiene nada especifico de Windows: toda la lectura de // Deliberately has NOTHING Windows-specific: all memory reading lives in
// memoria vive en main.go. Asi esta parte, que es la que concentra las // main.go. That keeps this part — which holds the actual rules (which
// reglas (a que personaje va cada muerte, que pasa al cambiar de // character a death belongs to, what happens on a character switch, what
// partida, que se muestra con el juego cerrado), se puede compilar y // to show with the game closed) — buildable and testable on any platform.
// testear en cualquier plataforma. Las tres veces que algo conto mal, el // All three times something counted wrong, the bug was here.
// error estuvo aca.
package main package main
import ( import (
@@ -14,9 +13,9 @@ import (
"sync" "sync"
) )
// El total que se muestra ES el que lleva el juego para el personaje // The total shown IS the one the game keeps for the loaded character. No
// cargado. No hay ajustes ni acumuladores propios: al cambiar de // adjustments or accumulators of our own: switching characters jumps the
// personaje el numero salta al suyo en la misma lectura. // number to theirs on the very same reading.
type state struct { type state struct {
mu sync.Mutex mu sync.Mutex
connected bool connected bool
@@ -45,8 +44,8 @@ type stateSnapshot struct {
var st state var st state
var totals *totalsStore var totals *totalsStore
// init arranca mostrando el ultimo personaje conocido, para que el // init starts by showing the last known character, so the overlay has a
// overlay tenga un numero con sentido antes de que abras el juego. // sensible number before you even open the game.
func (s *state) init(store *totalsStore) { func (s *state) init(store *totalsStore) {
key, name, total := store.lastKnownTotal() key, name, total := store.lastKnownTotal()
s.mu.Lock() s.mu.Lock()
@@ -56,7 +55,7 @@ func (s *state) init(store *totalsStore) {
s.total = total s.total = total
s.mu.Unlock() s.mu.Unlock()
if name != "" { if name != "" {
log.Printf("ultimo personaje conocido: %s con %d muertes", name, total) log.Printf("last known character: %s with %d deaths", name, total)
} }
} }
@@ -84,32 +83,32 @@ func (s *state) name() string {
return s.charName return s.charName
} }
// nombreMostrado: lo que diga el config manda; si esta vacio usamos el // displayName: whatever the config says wins; if it's empty, fall back to
// nombre del personaje que leemos del juego. // the character name read from the game.
func nombreMostrado(cfg config) string { func displayName(cfg config) string {
if n := strings.TrimSpace(cfg.Name); n != "" { if n := strings.TrimSpace(cfg.Name); n != "" {
return n return n
} }
if n := st.name(); n != "" { if n := st.name(); n != "" {
return n return n
} }
// Vacio a proposito: que la etiqueta la elija la interfaz, que es la // Empty on purpose: let the UI pick the label, since it's the one that
// que sabe si esta mostrando un jugador o dos. // knows whether it's showing one player or two.
return "" return ""
} }
// setCharacter se llama cuando cambia el personaje cargado: el total // setCharacter is called when the loaded character changes: the total
// pasa a ser el que el juego lleva para ese personaje. // becomes whatever the game tracks for that character.
func (s *state) setCharacter(slot int, name string, raw int32) { func (s *state) setCharacter(slot int, name string, raw int32) {
key := characterKey(slot, name) key := characterKey(slot, name)
if key == "" { if key == "" {
return // ni slot ni nombre: no sabemos a quien imputarle esto return // neither slot nor name: no one to attribute this to
} }
s.mu.Lock() s.mu.Lock()
if key == s.charKey && s.haveReading { if key == s.charKey && s.haveReading {
// Mismo personaje: el nombre puede haber aparecido recien, porque // Same character: the name may have just shown up, since the slot
// el slot se lee antes que el nombre. // is read before the name.
if name != "" && name != s.charName { if name != "" && name != s.charName {
s.charName = name s.charName = name
total := s.total total := s.total
@@ -126,16 +125,15 @@ func (s *state) setCharacter(slot int, name string, raw int32) {
s.charName = name s.charName = name
} }
name = s.charName name = s.charName
// Adoptamos ya la lectura del personaje nuevo: si no, el snapshot // Adopt the new character's reading right away: otherwise the snapshot
// seguiria mostrando las muertes del personaje ANTERIOR hasta la // would keep showing the PREVIOUS character's deaths until the next one.
// siguiente muerte.
s.rawDeaths = raw s.rawDeaths = raw
s.haveReading = true s.haveReading = true
s.total = int64(raw) s.total = int64(raw)
total := s.total total := s.total
s.mu.Unlock() s.mu.Unlock()
log.Printf("personaje: %s (slot %d) -> %d muertes", name, slot, total) log.Printf("character: %s (slot %d) -> %d deaths", name, slot, total)
totals.save(key, charTotal{Name: name, Total: total}) totals.save(key, charTotal{Name: name, Total: total})
} }
@@ -159,16 +157,16 @@ func (s *state) setReading(raw int32, boss bool) {
return return
} }
if grew { if grew {
log.Printf("nueva muerte de %s -> %d", name, total) log.Printf("new death for %s -> %d", name, total)
} }
totals.save(key, charTotal{Name: name, Total: total}) totals.save(key, charTotal{Name: name, Total: total})
} }
// setPlayerUnloaded: el juego sigue abierto pero no hay personaje en el // setPlayerUnloaded: the game is still open but there's no character in
// mundo (menu principal o pantalla de carga). A diferencia de una // the world (main menu or a loading screen). Unlike a disconnect, this
// desconexion, NO apagamos connected: el overlay tiene que seguir // does NOT clear connected: the overlay has to keep showing the total
// mostrando el total y no un guion, porque si no parpadearia en cada // instead of a dash, or it would flicker on every loading screen (i.e.
// pantalla de carga (o sea, en cada muerte). // every death).
func (s *state) setPlayerUnloaded(reason string) { func (s *state) setPlayerUnloaded(reason string) {
s.mu.Lock() s.mu.Lock()
was := s.playerLoaded was := s.playerLoaded
@@ -180,7 +178,7 @@ func (s *state) setPlayerUnloaded(reason string) {
s.lastErr = reason s.lastErr = reason
s.mu.Unlock() s.mu.Unlock()
if was || first { if was || first {
log.Printf("sin personaje cargado (%s) - el total queda congelado hasta que vuelvas al mundo", reason) log.Printf("no character loaded (%s) - total stays frozen until you're back in the world", reason)
} }
} }
@@ -193,6 +191,6 @@ func (s *state) setDisconnected(reason string) {
s.lastErr = reason s.lastErr = reason
s.mu.Unlock() s.mu.Unlock()
if wasConnected { if wasConnected {
log.Printf("se perdio la conexion con el juego: %s", reason) log.Printf("lost the connection to the game: %s", reason)
} }
} }
+30 -29
View File
@@ -6,8 +6,8 @@ import (
"testing" "testing"
) )
// cada test corre en su propio directorio: totals.json y offset.txt // each test runs in its own directory: totals.json and offset.txt resolve
// se resuelven contra el ejecutable, asi que lo apuntamos al temporal. // relative to the executable, so we point that at the temp dir.
func fresh(t *testing.T, legacy string) { func fresh(t *testing.T, legacy string) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
@@ -24,90 +24,91 @@ func fresh(t *testing.T, legacy string) {
func total(t *testing.T) int64 { t.Helper(); return st.snapshot().Total } func total(t *testing.T) int64 { t.Helper(); return st.snapshot().Total }
// EL CASO DE EMMA: cambiar de personaje tiene que mover el numero YA, // THE EMMA CASE: switching characters has to move the number RIGHT AWAY,
// sin esperar a que alguien se muera. // without waiting for anyone to die.
func TestCambioDePersonajeActualizaAlInstante(t *testing.T) { func TestCharacterSwitchUpdatesInstantly(t *testing.T) {
fresh(t, "") fresh(t, "")
st.setCharacter(0, "emma", 40) st.setCharacter(0, "emma", 40)
st.setReading(40, false) st.setReading(40, false)
if got := total(t); got != 40 { if got := total(t); got != 40 {
t.Fatalf("emma deberia arrancar con sus 40 muertes, dio %d", got) t.Fatalf("emma should start at her 40 deaths, got %d", got)
} }
// se va al menu y carga otro personaje, con otro conteo propio // goes to the menu and loads another character, with its own count
st.setPlayerUnloaded("menu") st.setPlayerUnloaded("menu")
st.setCharacter(1, "emmatherock", 7) st.setCharacter(1, "emmatherock", 7)
if got := total(t); got != 7 { if got := total(t); got != 7 {
t.Fatalf("al cargar emmatherock el total tenia que saltar a 7 SIN morir, dio %d", got) t.Fatalf("loading emmatherock should jump the total to 7 WITHOUT dying, got %d", got)
} }
st.setReading(7, false) st.setReading(7, false)
if got := total(t); got != 7 { if got := total(t); got != 7 {
t.Fatalf("tras la lectura deberia seguir en 7, dio %d", got) t.Fatalf("after the reading it should still be 7, got %d", got)
} }
// y volver muestra lo que el juego lleva para emma // and switching back shows what the game tracks for emma
st.setPlayerUnloaded("menu") st.setPlayerUnloaded("menu")
st.setCharacter(0, "emma", 41) st.setCharacter(0, "emma", 41)
st.setReading(41, false) st.setReading(41, false)
if got := total(t); got != 41 { if got := total(t); got != 41 {
t.Fatalf("volviendo a emma deberia dar 41, dio %d", got) t.Fatalf("switching back to emma should give 41, got %d", got)
} }
} }
// Dos personajes con el MISMO nombre no se tienen que mezclar. // Two characters with the SAME name must not get mixed up.
func TestHomonimosNoSeMezclan(t *testing.T) { func TestHomonymsDontMix(t *testing.T) {
fresh(t, "") fresh(t, "")
st.setCharacter(0, "Tarnished", 100) st.setCharacter(0, "Tarnished", 100)
st.setReading(100, false) st.setReading(100, false)
st.setCharacter(3, "Tarnished", 5) st.setCharacter(3, "Tarnished", 5)
st.setReading(5, false) st.setReading(5, false)
if got := total(t); got != 5 { if got := total(t); got != 5 {
t.Fatalf("el del slot 3 deberia dar 5, dio %d", got) t.Fatalf("slot 3's should give 5, got %d", got)
} }
if c, ok := totals.get("slot0"); !ok || c.Total != 100 { if c, ok := totals.get("slot0"); !ok || c.Total != 100 {
t.Fatalf("el del slot 0 deberia seguir en 100, dio %+v", c) t.Fatalf("slot 0's should still be 100, got %+v", c)
} }
} }
// El total es el del juego, sin importar que haya un offset.txt viejo // The total is the game's, no matter if a stale offset.txt is lying
// dando vueltas al lado del programa. // around next to the program.
func TestIgnoraElOffsetViejo(t *testing.T) { func TestIgnoresStaleOffset(t *testing.T) {
fresh(t, "124") fresh(t, "124")
st.setCharacter(0, "emma", 30) st.setCharacter(0, "emma", 30)
st.setReading(30, false) st.setReading(30, false)
if got := total(t); got != 30 { if got := total(t); got != 30 {
t.Fatalf("deberia mostrar las 30 del juego y no las 124 del archivo viejo, dio %d", got) t.Fatalf("should show the game's 30, not the old file's 124, got %d", got)
} }
} }
// Sin poder leer el slot, cae al nombre y sigue funcionando. // Without being able to read the slot, it falls back to the name and
func TestSinSlotCaeAlNombre(t *testing.T) { // keeps working.
func TestNoSlotFallsBackToName(t *testing.T) {
fresh(t, "") fresh(t, "")
st.setCharacter(-1, "emma", 40) st.setCharacter(-1, "emma", 40)
st.setReading(40, false) st.setReading(40, false)
if got := total(t); got != 40 { if got := total(t); got != 40 {
t.Fatalf("deberia dar 40, dio %d", got) t.Fatalf("should give 40, got %d", got)
} }
if _, ok := totals.get("name:emma"); !ok { if _, ok := totals.get("name:emma"); !ok {
t.Fatal("deberia haberlo guardado por nombre") t.Fatal("should have saved it by name")
} }
} }
// Al reabrir el programa con el juego cerrado, muestra el ultimo // Reopening the program with the game closed shows the last known
// personaje conocido en vez de un cero. // character instead of a zero.
func TestRecuerdaElUltimoAlReabrir(t *testing.T) { func TestRemembersTheLastOneOnReopen(t *testing.T) {
fresh(t, "") fresh(t, "")
st.setCharacter(2, "emma", 88) st.setCharacter(2, "emma", 88)
st.setReading(88, false) st.setReading(88, false)
totals = newTotalsStore() // simula reabrir el programa totals = newTotalsStore() // simulates reopening the program
st = state{} st = state{}
st.init(totals) st.init(totals)
if got := total(t); got != 88 { if got := total(t); got != 88 {
t.Fatalf("al reabrir deberia mostrar 88, dio %d", got) t.Fatalf("reopening should show 88, got %d", got)
} }
if st.snapshot().CharName != "emma" { if st.snapshot().CharName != "emma" {
t.Fatalf("y recordar el nombre, dio %q", st.snapshot().CharName) t.Fatalf("and remember the name, got %q", st.snapshot().CharName)
} }
} }
+18 -18
View File
@@ -1,15 +1,15 @@
//go:build windows //go:build windows
// i18n.go: idiomas de la interfaz. // i18n.go: interface languages.
// //
// Las traducciones viven en locales/*.json y se embeben en el ejecutable. // Translations live in locales/*.json and get embedded into the binary.
// La razon de elegir archivos sueltos en vez de un mapa en el codigo es // The reason for separate files instead of a map in the code is so
// que agregar un idioma sea "copiar en.json, traducirlo, y listo": quien // adding a language is "copy en.json, translate it, done": whoever
// traduce no necesita saber Go ni tocar una linea de codigo. // translates doesn't need to know Go or touch a line of code.
// //
// Los mensajes de la consola NO pasan por aca a proposito: son // Console messages deliberately do NOT go through here: they're
// diagnostico, y conviene que esten siempre en el mismo idioma para que // diagnostics, and it helps if they're always in the same language so a
// un log pegado en un issue se pueda leer sin importar de donde venga. // log pasted into an issue reads the same no matter where it came from.
package main package main
import ( import (
@@ -33,7 +33,7 @@ var locales = map[string]map[string]string{}
func loadLocales() { func loadLocales() {
entries, err := localeFS.ReadDir("locales") entries, err := localeFS.ReadDir("locales")
if err != nil { if err != nil {
log.Printf("no pude leer los idiomas embebidos: %v", err) log.Printf("couldn't read the embedded languages: %v", err)
return return
} }
for _, e := range entries { for _, e := range entries {
@@ -43,12 +43,12 @@ func loadLocales() {
} }
data, err := localeFS.ReadFile(path.Join("locales", name)) data, err := localeFS.ReadFile(path.Join("locales", name))
if err != nil { if err != nil {
log.Printf("no pude leer locales/%s: %v", name, err) log.Printf("couldn't read locales/%s: %v", name, err)
continue continue
} }
var m map[string]string var m map[string]string
if err := json.Unmarshal(data, &m); err != nil { if err := json.Unmarshal(data, &m); err != nil {
log.Printf("locales/%s tiene JSON invalido: %v", name, err) log.Printf("locales/%s has invalid JSON: %v", name, err)
continue continue
} }
locales[strings.TrimSuffix(name, ".json")] = m locales[strings.TrimSuffix(name, ".json")] = m
@@ -66,7 +66,7 @@ func availableLangs() []string {
var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName")
// systemLang devuelve el idioma de Windows ("es-AR" -> "es"). // systemLang returns Windows's language ("es-AR" -> "es").
func systemLang() string { func systemLang() string {
buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH
r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
@@ -80,8 +80,8 @@ func systemLang() string {
return strings.ToLower(name) return strings.ToLower(name)
} }
// resolveLang decide el idioma final. "auto" (o vacio) usa el de // resolveLang decides the final language. "auto" (or empty) uses
// Windows; si ese idioma no esta traducido, cae a ingles. // Windows's; if that language isn't translated, it falls back to English.
func resolveLang(want string) string { func resolveLang(want string) string {
want = strings.ToLower(strings.TrimSpace(want)) want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "auto" { if want == "" || want == "auto" {
@@ -93,7 +93,7 @@ func resolveLang(want string) string {
if _, ok := locales[want]; ok { if _, ok := locales[want]; ok {
return want return want
} }
// "es-AR" o "es_AR" igual tienen que encontrar "es". // "es-AR" or "es_AR" should still resolve to "es".
if base, _, ok := strings.Cut(strings.NewReplacer("_", "-").Replace(want), "-"); ok { if base, _, ok := strings.Cut(strings.NewReplacer("_", "-").Replace(want), "-"); ok {
if _, ok := locales[base]; ok { if _, ok := locales[base]; ok {
return base return base
@@ -102,9 +102,9 @@ func resolveLang(want string) string {
return fallbackLang return fallbackLang
} }
// stringsFor devuelve el diccionario del idioma pedido, completado con // stringsFor returns the requested language's dictionary, filled in with
// el ingles: si una traduccion quedo a medias, se ve la clave que falta // English: if a translation is incomplete, the missing key shows up in
// en ingles y no la clave cruda. // English instead of as a raw key.
func stringsFor(lang string) map[string]string { func stringsFor(lang string) map[string]string {
out := map[string]string{} out := map[string]string{}
for k, v := range locales[fallbackLang] { for k, v := range locales[fallbackLang] {
+22 -20
View File
@@ -40,8 +40,8 @@
border-top:1px solid var(--gold); border-bottom:1px solid var(--gold); border-top:1px solid var(--gold); border-bottom:1px solid var(--gold);
padding:18px 24px; width:600px; box-sizing:border-box; box-shadow:var(--shadow); padding:18px 24px; width:600px; box-sizing:border-box; box-shadow:var(--shadow);
} }
/* Con un solo jugador la placa se angosta; el canvas de OBS no cambia, /* With a single player the plate narrows; the OBS canvas doesn't
simplemente ocupa menos. Asi las dimensiones se configuran una vez. */ change, it just takes up less of it. So the dimensions get set once. */
.plate.solo{width:420px;} .plate.solo{width:420px;}
.players{display:flex;align-items:stretch;justify-content:center;} .players{display:flex;align-items:stretch;justify-content:center;}
.pcol{flex:1 1 0;min-width:0;text-align:center;} .pcol{flex:1 1 0;min-width:0;text-align:center;}
@@ -55,9 +55,10 @@
transition:color .15s ease, transform .15s ease; transition:color .15s ease, transform .15s ease;
} }
.pnum.pulse{color:var(--gold-bright);transform:scale(1.06);} .pnum.pulse{color:var(--gold-bright);transform:scale(1.06);}
/* Una sola linea bajo el numero: dice "muertes" y, cuando hay algo que /* A single line under the number: says "deaths" and, when there's
contar (jefe, sin conexion), lo agrega ahi mismo. Asi la palabra something else to report (boss, no connection), appends it right
siempre esta y el alto del overlay no cambia nunca. */ there. That way the word is always present and the overlay's height
never changes. */
.pnote{ .pnote{
font-family:'Cinzel', Georgia, serif;font-size:.7rem;letter-spacing:.22em;text-transform:uppercase; font-family:'Cinzel', Georgia, serif;font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--muted);min-height:1.5em;margin-top:.35rem; color:var(--muted);min-height:1.5em;margin-top:.35rem;
@@ -166,9 +167,9 @@
(function(){ (function(){
var mode = window.__mode || 'control'; var mode = window.__mode || 'control';
// Textos de la interfaz. El diccionario real viene de /strings.json // Interface text. The real dictionary comes from /strings.json (see
// (ver i18n.go y locales/*.json). Este de aca es el bote salvavidas: // i18n.go and locales/*.json). This one here is the lifeboat: if that
// si esa peticion falla, se ve ingles y nunca una clave cruda. // request fails, you see English and never a raw key.
var FALLBACK = { var FALLBACK = {
"overlay.deaths": "deaths", "overlay.deaths": "deaths",
"overlay.boss": "boss", "overlay.boss": "boss",
@@ -239,18 +240,18 @@
el.className = 'status' + (cls ? ' ' + cls : ''); el.className = 'status' + (cls ? ' ' + cls : '');
} }
// Cache de las columnas: reconstruir el DOM en cada tick haria // Column cache: rebuilding the DOM on every tick would make the
// parpadear el overlay y perderia la animacion del pulso. // overlay flicker and lose the pulse animation.
// //
// Se indexan por POSICION, nunca por nombre. Indexarlas por nombre fue // Indexed by POSITION, never by name. Indexing by name was a real bug:
// un bug real: al cambiar de personaje cambiaba la clave, se creaba una // switching characters changed the key, spawning a new column and
// columna nueva y la vieja quedaba colgada en el DOM, asi que un mismo // leaving the old one stuck in the DOM, so the same player showed up
// jugador aparecia dos veces. El nombre es contenido, no identidad. // twice. The name is content, not identity.
var slots = []; var slots = [];
var prevByName = {}; var prevByName = {};
// ensureSlots deja exactamente n columnas, creando o sacando las que // ensureSlots leaves exactly n columns, creating or removing whatever's
// hagan falta (con su divisor). // needed (with its divider).
function ensureSlots(list, n){ function ensureSlots(list, n){
while (slots.length > n){ while (slots.length > n){
var old = slots.pop(); var old = slots.pop();
@@ -291,7 +292,7 @@
for (var i = 0; i < players.length; i++){ for (var i = 0; i < players.length; i++){
var p = players[i]; var p = players[i];
var slot = slots[i]; var slot = slots[i];
// Sin nombre de personaje todavía: etiqueta según el modo. // No character name yet: label based on the mode.
slot.name.textContent = p.name || slot.name.textContent = p.name ||
(players.length < 2 ? t('overlay.deaths') : (p.self ? t('overlay.you') : t('overlay.partner'))); (players.length < 2 ? t('overlay.deaths') : (p.self ? t('overlay.you') : t('overlay.partner')));
slot.num.textContent = p.connected ? p.deaths : '–'; slot.num.textContent = p.connected ? p.deaths : '–';
@@ -363,13 +364,14 @@
setTimeout(poll, 1000); setTimeout(poll, 1000);
}); });
} }
// ?lang= en la URL pisa el idioma del config, comodo para tener el // ?lang= in the URL overrides the config's language, handy for having
// overlay en un idioma y el panel en otro sin tocar archivos. // the overlay in one language and the panel in another without
// touching any files.
var langParam = new URLSearchParams(location.search).get('lang'); var langParam = new URLSearchParams(location.search).get('lang');
fetch('/strings.json' + (langParam ? '?lang=' + encodeURIComponent(langParam) : ''), {cache:'no-store'}) fetch('/strings.json' + (langParam ? '?lang=' + encodeURIComponent(langParam) : ''), {cache:'no-store'})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ if (d && typeof d === 'object') STR = d; }) .then(function(d){ if (d && typeof d === 'object') STR = d; })
.catch(function(){ /* nos quedamos con FALLBACK */ }) .catch(function(){ /* stick with FALLBACK */ })
.finally(function(){ applyStrings(); poll(); }); .finally(function(){ applyStrings(); poll(); });
})(); })();
</script> </script>
+29 -30
View File
@@ -1,21 +1,21 @@
// totals.go: los totales, uno por personaje. // totals.go: the totals, one per character.
// //
// El juego ya lleva la cuenta de muertes de cada personaje por separado // The game already tracks each character's death count separately (it's
// (esta en la partida, no en la PC), asi que el programa hace lo mismo: // in the save, not on the PC), so the program does the same: for each
// para cada personaje guarda un "base", y el numero que se muestra es // character it saves a "base", and the number shown is always
// siempre total = muertes que lee del juego + base. // total = deaths read from the game + base.
// //
// El numero que se muestra es, sin intermediarios, el que lleva el juego // The number shown is, with nothing in between, whatever the game tracks
// para ese personaje. Lo que se guarda en disco NO es un ajuste: es solo // for that character. What's saved to disk is NOT an adjustment: it's
// lo ultimo que se vio, para poder mostrar algo cuando abris el contador // just the last thing seen, so there's something to show if you open the
// antes que el juego y todavia no hay nada que leer. // counter before the game and there's nothing to read yet.
// //
// Hubo dos modelos anteriores. Primero un unico total de por vida que // There were two earlier models. First a single lifetime total that
// nunca bajaba, que con varios personajes dejaba el numero quieto hasta // never went down, which with multiple characters left the number stuck
// la siguiente muerte. Despues un "base" por personaje que se sumaba a // until the next death. Then a per-character "base" added to the
// la lectura, heredado de cuando no confiabamos en lo que leiamos: una // reading, inherited from when we didn't trust what we were reading:
// vez que el total sale del juego en cada lectura, corregir a mano dejo // once the total comes straight from the game on every read, correcting
// de arreglar nada y solo servia para que el numero mintiera en silencio. // it by hand stopped fixing anything and just made the number lie quietly.
package main package main
import ( import (
@@ -30,7 +30,7 @@ import (
type charTotal struct { type charTotal struct {
Name string `json:"name"` Name string `json:"name"`
Total int64 `json:"total"` // ultimo total visto: para mostrar algo antes de leer el juego Total int64 `json:"total"` // last total seen: shows something before the game is read
} }
type totalsFile struct { type totalsFile struct {
@@ -44,7 +44,7 @@ type totalsStore struct {
data totalsFile data totalsFile
} }
// testExeDir permite a los tests apuntar a un directorio temporal. // testExeDir lets tests point this at a temp directory.
var testExeDir string var testExeDir string
func exeDir() (string, bool) { func exeDir() (string, bool) {
@@ -71,7 +71,7 @@ func newTotalsStore() *totalsStore {
data, err := os.ReadFile(s.path) data, err := os.ReadFile(s.path)
if err == nil { if err == nil {
if err := json.Unmarshal(data, &s.data); err != nil { if err := json.Unmarshal(data, &s.data); err != nil {
log.Printf("totals.json ilegible (%v): arranco de cero, no lo piso hasta la proxima escritura", err) log.Printf("totals.json is unreadable (%v): starting fresh, won't overwrite it until the next write", err)
s.data = totalsFile{Characters: map[string]*charTotal{}} s.data = totalsFile{Characters: map[string]*charTotal{}}
} }
if s.data.Characters == nil { if s.data.Characters == nil {
@@ -92,7 +92,7 @@ func (s *totalsStore) get(key string) (*charTotal, bool) {
return &cp, true return &cp, true
} }
// lastKnownTotal: que mostrar mientras el juego no este abierto. // lastKnownTotal: what to show while the game isn't open.
func (s *totalsStore) lastKnownTotal() (key, name string, total int64) { func (s *totalsStore) lastKnownTotal() (key, name string, total int64) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -109,28 +109,27 @@ func (s *totalsStore) save(key string, c charTotal) {
blob, err := json.MarshalIndent(s.data, "", " ") blob, err := json.MarshalIndent(s.data, "", " ")
s.mu.Unlock() s.mu.Unlock()
if err != nil { if err != nil {
log.Printf("no pude serializar totals.json: %v", err) log.Printf("couldn't serialize totals.json: %v", err)
return return
} }
// Escritura atomica: si se corta la luz a mitad, el archivo viejo // Atomic write: if the power cuts mid-write, the old file stays intact
// queda entero en vez de quedar un JSON cortado por la mitad. // instead of ending up as a JSON file cut in half.
tmp := s.path + ".tmp" tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, blob, 0644); err != nil { if err := os.WriteFile(tmp, blob, 0644); err != nil {
log.Printf("no pude escribir totals.json: %v", err) log.Printf("couldn't write totals.json: %v", err)
return return
} }
if err := os.Rename(tmp, s.path); err != nil { if err := os.Rename(tmp, s.path); err != nil {
log.Printf("no pude reemplazar totals.json: %v", err) log.Printf("couldn't replace totals.json: %v", err)
} }
} }
// characterKey: la identidad de un personaje. // characterKey: a character's identity.
// //
// El slot de guardado (GameMan+0xAC0) es la identidad buena: no cambia // The save slot (GameMan+0xAC0) is the good identity: it never changes,
// nunca y dos personajes que se llamen igual siguen siendo distintos. El // and two characters with the same name stay distinct. The name is only
// nombre queda solo como respaldo para cuando no podemos leer el slot, // a fallback for when we can't read the slot, with the known limitation
// con la limitacion conocida de que ahi si dos homonimos comparten // that there, two homonyms DO share a counter.
// contador.
func characterKey(slot int, name string) string { func characterKey(slot int, name string) string {
if slot >= 0 { if slot >= 0 {
return "slot" + strconv.Itoa(slot) return "slot" + strconv.Itoa(slot)