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