package main import ( "encoding/json" "math" "strings" "testing" "countrygame/web" ) var ( docTypes = set([]string{"memo", "telegram", "petition", "report", "letter"}) cardKeys = set([]string{"id", "category", "doc", "sender", "weight", "once", "cooldownDays", "followUpOnly", "conditions", "title", "body", "yes", "no"}) optionKeys = set([]string{"label", "effects", "setFlags", "clearFlags", "followUp"}) condKeys = set([]string{"min", "max", "flags", "notFlags", "dayMin", "dayMax"}) effectKeys = set([]string{"stat", "sector", "revenue", "oneOff", "extraSpending", "delta", "days", "bias"}) ) func isLocalized(v any) bool { m, ok := v.(obj) if !ok { return false } for _, l := range []string{"en", "fr"} { s, ok := m[l].(string) if !ok || strings.TrimSpace(s) == "" { return false } } return len(m) == 2 } func isWholeNumber(v any) bool { f, ok := v.(float64) return ok && f >= 1 && f == math.Trunc(f) } // cardChecker collects cross-card facts while validating one card at a time. type cardChecker struct { t *testing.T sectors map[string]bool stats map[string]bool condStats map[string]bool revenues map[string]bool ids map[string]bool setFlags map[string]bool usedFlags map[string]string followTargets map[string]string } func (c *cardChecker) effects(id, side string, effects any) { t := c.t list, _ := effects.([]any) for i, raw := range list { where := id + "." + side + ".effects[" + string(rune('0'+i)) + "]" e, ok := raw.(obj) if !ok { t.Errorf("%s is not an object", where) continue } for k := range e { if !effectKeys[k] { t.Errorf("%s: unknown field %q", where, k) } } kinds := 0 for _, k := range []string{"stat", "sector", "revenue", "oneOff", "extraSpending"} { if _, ok := e[k]; ok { kinds++ } } if kinds != 1 { t.Errorf("%s: needs exactly one of stat/sector/revenue/oneOff/extraSpending", where) } if s, ok := e["stat"].(string); ok && !c.stats[s] { t.Errorf("%s: unknown stat %q", where, s) } if s, ok := e["sector"].(string); ok && !c.sectors[s] { t.Errorf("%s: unknown sector %q", where, s) } if s, ok := e["revenue"].(string); ok && !c.revenues[s] { t.Errorf("%s: unknown revenue %q", where, s) } _, isStat := e["stat"] _, isSector := e["sector"] _, isRevenue := e["revenue"] if isStat || isSector || isRevenue { if _, ok := e["delta"].(float64); !ok { t.Errorf("%s: missing numeric delta", where) } } if d, ok := e["days"]; ok && !isWholeNumber(d) { t.Errorf("%s: days must be a whole number >= 1", where) } if b, ok := e["bias"]; ok { if _, isBool := b.(bool); !isBool { t.Errorf("%s: bias must be a boolean", where) } if !isStat && !isSector { t.Errorf("%s: bias only applies to stat/sector effects", where) } } } } func (c *cardChecker) conditions(id string, cond obj) { t := c.t for k := range cond { if !condKeys[k] { t.Errorf("%s.conditions: unknown field %q", id, k) } } for _, bound := range []string{"min", "max"} { m, _ := cond[bound].(obj) for k, v := range m { okKey := c.condStats[k] || (strings.HasPrefix(k, "q:") && c.sectors[strings.TrimPrefix(k, "q:")]) || (strings.HasPrefix(k, "ratio:") && c.sectors[strings.TrimPrefix(k, "ratio:")]) || (strings.HasPrefix(k, "tax:") && c.revenues[strings.TrimPrefix(k, "tax:")] && k != "tax:other") if !okKey { t.Errorf("%s.conditions.%s: unknown stat %q", id, bound, k) } if _, isNum := v.(float64); !isNum { t.Errorf("%s.conditions.%s.%s must be a number", id, bound, k) } } } for _, fl := range []string{"flags", "notFlags"} { list, _ := cond[fl].([]any) for _, x := range list { c.usedFlags[x.(string)] = id } } lo, hasLo := cond["dayMin"].(float64) hi, hasHi := cond["dayMax"].(float64) if hasLo && hasHi && lo > hi { t.Errorf("%s.conditions: dayMin > dayMax", id) } } func (c *cardChecker) option(id, side string, opt obj) { t := c.t for k := range opt { if !optionKeys[k] { t.Errorf("%s.%s: unknown field %q", id, side, k) } } if !isLocalized(opt["label"]) { t.Errorf("%s.%s.label needs non-empty en and fr", id, side) } c.effects(id, side, opt["effects"]) flags, _ := opt["setFlags"].([]any) for _, x := range flags { c.setFlags[x.(string)] = true } if fu, ok := opt["followUp"].(obj); ok { target, _ := fu["id"].(string) c.followTargets[target] = id if !isWholeNumber(fu["inDays"]) { t.Errorf("%s.%s.followUp.inDays must be a whole number >= 1", id, side) } } } func TestCards(t *testing.T) { scn := load(t, "data/scenarios/default.json") bal := load(t, "data/balance.json") chars := load(t, "data/characters.json") index := load(t, "data/cards/index.json") c := &cardChecker{ t: t, sectors: set(keys(scn["sectors"].(obj))), stats: set(append(append([]string{}, indIDs...), varIDs...)), condStats: set(append([]string{"debt", "deficit"}, append(append([]string{}, indIDs...), varIDs...)...)), revenues: set(append([]string{"other"}, taxIDs...)), ids: map[string]bool{}, setFlags: map[string]bool{}, usedFlags: map[string]string{}, followTargets: map[string]string{}, } categories := set(append(keys(bal["cards"].(obj)["boost"].(obj)), "crises")) // Every card file must be in the manifest and vice versa. listed := map[string]bool{} for _, f := range index["files"].([]any) { listed[f.(string)] = true } entries, err := web.Files.ReadDir("data/cards") if err != nil { t.Fatal(err) } for _, e := range entries { if e.Name() != "index.json" && !listed[e.Name()] { t.Errorf("cards/%s is not listed in cards/index.json", e.Name()) } delete(listed, e.Name()) } for f := range listed { t.Errorf("cards/index.json lists %s but the file does not exist", f) } followOnly := map[string]bool{} total := 0 perCategory := map[string]int{} for _, f := range index["files"].([]any) { file := f.(string) raw, err := web.Files.ReadFile("data/cards/" + file) if err != nil { t.Errorf("read %s: %v", file, err) continue } var list []obj if err := json.Unmarshal(raw, &list); err != nil { t.Errorf("parse %s: %v", file, err) continue } stem := strings.TrimSuffix(file, ".json") for _, card := range list { id, _ := card["id"].(string) if id == "" { t.Errorf("%s: card without id", file) continue } total++ perCategory[stem]++ if c.ids[id] { t.Errorf("duplicate card id %s", id) } c.ids[id] = true if only, _ := card["followUpOnly"].(bool); only { followOnly[id] = true } for k := range card { if !cardKeys[k] { t.Errorf("%s: unknown field %q", id, k) } } if cat, _ := card["category"].(string); cat != stem || !categories[cat] { t.Errorf("%s: category %q must match its file (%s) and be a known category", id, card["category"], stem) } if d, _ := card["doc"].(string); !docTypes[d] { t.Errorf("%s: unknown doc type %q", id, card["doc"]) } if s, _ := card["sender"].(string); chars[s] == nil { t.Errorf("%s: unknown sender %q", id, card["sender"]) } if !isLocalized(card["title"]) || !isLocalized(card["body"]) { t.Errorf("%s: title and body need non-empty en and fr", id) } if w, ok := card["weight"]; ok { if f, isNum := w.(float64); !isNum || f <= 0 { t.Errorf("%s: weight must be positive", id) } } if cd, ok := card["cooldownDays"]; ok && !isWholeNumber(cd) { t.Errorf("%s: cooldownDays must be a whole number >= 1", id) } if cond, ok := card["conditions"].(obj); ok { c.conditions(id, cond) } for _, side := range []string{"yes", "no"} { opt, ok := card[side].(obj) if !ok { t.Errorf("%s: missing %s option", id, side) continue } c.option(id, side, opt) } } } for target, from := range c.followTargets { if !c.ids[target] { t.Errorf("%s: follow-up %q does not exist", from, target) } } for id := range followOnly { if c.followTargets[id] == "" { t.Errorf("%s is followUpOnly but no card leads to it", id) } } for fl, by := range c.usedFlags { if !c.setFlags[fl] { t.Errorf("%s: flag %q is used in conditions but never set by any card", by, fl) } } if total < 800 { t.Errorf("only %d cards; M7 expects at least 800", total) } minPerCategory := map[string]int{ "economy": 90, "social": 60, "health": 55, "education": 55, "security": 60, "defense": 55, "foreign": 60, "industry": 60, "transport": 55, "housing": 35, "environment": 45, "justice": 55, "culture": 35, "crises": 45, } for cat, min := range minPerCategory { if perCategory[cat] < min { t.Errorf("category %s has %d cards; M7 expects at least %d", cat, perCategory[cat], min) } } }