188 lines
5.9 KiB
Go
188 lines
5.9 KiB
Go
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) != ""
|
||
}
|