package main import ( "strings" "hamlog/internal/award" "hamlog/internal/qso" ) // New-grid scoping: WHICH prior contacts count as "I already have this square". // // The question has no single right answer, which is why it is a setting rather // than a rule. A VUCC chaser counts a square per band; someone filling in a // wall map counts it once. GridTracker offers the same six combinations, and an // operator running both alongside each other needs them to agree — a square // "wanted" in one and not the other is a bug report every time. const ( keyGridScope = "grid.scope" // band+mode combination — see gridScopes keyGridHunt = "grid.hunt" // "new" | "new_unconfirmed" ) // gridScope is one band × mode combination. type gridScope struct { Key string `json:"key"` // Band is true when the square has to have been worked on THIS band. False // is GridTracker's "Mix Bands": any band counts. Band bool `json:"band"` // Mode is how much of the mode matters: // "digi" — any digital mode at all // "ftx" — any of the FT family (FT8/FT4/FT2) // "mode" — this exact mode and no other Mode string `json:"mode"` } var gridScopes = []gridScope{ {Key: "band_digi", Band: true, Mode: "digi"}, {Key: "band_mode", Band: true, Mode: "mode"}, {Key: "band_ftx", Band: true, Mode: "ftx"}, {Key: "mix_digi", Band: false, Mode: "digi"}, {Key: "mix_mode", Band: false, Mode: "mode"}, {Key: "mix_ftx", Band: false, Mode: "ftx"}, } // defaultGridScope matches what the panel showed before the setting existed: // any band, any digital mode. Changing an operator's badges on upgrade is worse // than offering them a choice they have not made yet. const defaultGridScope = "mix_digi" func lookupGridScope(key string) gridScope { for _, s := range gridScopes { if s.Key == key { return s } } for _, s := range gridScopes { if s.Key == defaultGridScope { return s } } return gridScopes[0] } // ftxModes is the FT family, which is a narrower thing than "digital": an // operator chasing squares on FT8 does not count the RTTY contest contact, and // GridTracker draws the same line. var ftxModes = map[string]bool{"FT8": true, "FT4": true, "FT2": true} // gridClassesOf lists every bucket one logged mode belongs to. A single FT8 // contact counts under its own name, under FTx and under digital — the scopes // overlap by construction, so the index stores all three and the lookup picks. func gridClassesOf(mode string) []string { m := strings.ToUpper(strings.TrimSpace(mode)) if m == "" { return nil } out := []string{"m:" + m} if ftxModes[m] { out = append(out, "ftx") } if award.ModeClass(m) == "DIGI" { out = append(out, "digi") } return out } // gridClassFor turns the scope plus the mode being heard into the one class to // look up. Empty means "this scope cannot judge this mode" — a phone contact // under an FTx scope — and the caller then flags nothing rather than guessing. func gridClassFor(s gridScope, mode string) string { m := strings.ToUpper(strings.TrimSpace(mode)) if m == "" { return "" } switch s.Mode { case "mode": return "m:" + m case "ftx": if ftxModes[m] { return "ftx" } return "" default: // digi if award.ModeClass(m) == "DIGI" { return "digi" } return "" } } // GridScopeSettings is the panel's shape. type GridScopeSettings struct { Scope string `json:"scope"` Hunt string `json:"hunt"` // Scopes is the list the UI renders, so it cannot drift from the one the // lookup honours. Scopes []gridScope `json:"scopes"` } func (a *App) GetGridScopeSettings() GridScopeSettings { return GridScopeSettings{ Scope: lookupGridScope(a.settingOr(keyGridScope, "")).Key, Hunt: gridHunt(a.settingOr(keyGridHunt, "")), Scopes: gridScopes, } } // gridHunt normalises the hunting mode. "new" is never-worked; "new_unconfirmed" // also chases a square worked but not yet confirmed — the same square is still // missing from the award either way. func gridHunt(v string) string { if strings.TrimSpace(v) == "new_unconfirmed" { return "new_unconfirmed" } return "new" } func (a *App) SaveGridScopeSettings(s GridScopeSettings) error { a.setSetting(keyGridScope, lookupGridScope(s.Scope).Key) a.setSetting(keyGridHunt, gridHunt(s.Hunt)) // The worked-grid index is built under these rules, so it is now stale. a.clusterStatusMu.Lock() a.clusterStatusIdx = nil a.clusterStatusMu.Unlock() return nil } // Grid states a heard square can be in, from the operator's point of view. // // "wanted" is not one state but two, and they call for different decisions: a // square never worked is a QSO to make, one worked and not yet confirmed is a // QSL to chase. Reporting both as plain NEW GRID — which is what this did when // the unconfirmed hunt was added — leaves an operator staring at a badge on a // station the log plainly says they worked an hour ago. const ( gridStateNew = "new" // never worked under this scope gridStateUnconf = "unconf" // worked under this scope, not yet confirmed ) // gridStateFor classifies one heard square. "" means nothing to chase: already // confirmed, or a mode this scope cannot judge. // // band is where it was heard and mode what it was heard in — the SCOPE decides // whether either is allowed to matter. func gridStateFor(idx map[string]bool, s gridScope, hunt, grid, band, mode string) string { cls := gridClassFor(s, mode) if cls == "" || len(grid) < 4 { return "" } key := qso.GridKey(strings.ToUpper(grid[:4]), "", cls) if s.Band { key = qso.GridKey(strings.ToUpper(grid[:4]), band, cls) } confirmed, worked := idx[key] switch { case !worked: return gridStateNew case !confirmed && hunt == "new_unconfirmed": return gridStateUnconf default: return "" } } // gridIsWanted is gridStateFor reduced to the yes/no the filters need. func gridIsWanted(idx map[string]bool, s gridScope, hunt, grid, band, mode string) bool { return gridStateFor(idx, s, hunt, grid, band, mode) != "" }