Files
Country-Game---The-Desk/server/cards_test.go
T

288 lines
7.9 KiB
Go

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:")])
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
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++
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 < 60 {
t.Errorf("only %d cards; M4 expects at least 60", total)
}
}