First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
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)
}
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"encoding/json"
"sort"
"testing"
"countrygame/web"
)
var (
indIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty"}
varIDs = []string{"growth", "unemployment", "inflation"}
taxIDs = []string{"income", "corporate", "vat"}
devKeys = []string{"growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"}
)
type obj = map[string]any
func load(t *testing.T, path string) obj {
t.Helper()
raw, err := web.Files.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out obj
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
func keys(m obj) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func set(list []string) map[string]bool {
m := make(map[string]bool, len(list))
for _, s := range list {
m[s] = true
}
return m
}
func sameKeys(t *testing.T, what string, got obj, want []string) {
t.Helper()
w := append([]string(nil), want...)
sort.Strings(w)
g := keys(got)
if len(g) != len(w) {
t.Errorf("%s: keys %v, want %v", what, g, w)
return
}
for i := range g {
if g[i] != w[i] {
t.Errorf("%s: keys %v, want %v", what, g, w)
return
}
}
}
// checkWeights verifies every key of def[field] is in the allowed set.
func checkWeights(t *testing.T, who string, def obj, field string, allowed map[string]bool) {
t.Helper()
m, ok := def[field].(obj)
if !ok {
return // optional section
}
for k := range m {
if !allowed[k] {
t.Errorf("%s.%s references unknown %q", who, field, k)
}
}
}
func TestBalanceAndScenario(t *testing.T) {
bal := load(t, "data/balance.json")
scn := load(t, "data/scenarios/default.json")
sectors := scn["sectors"].(obj)
sectorSet := make(map[string]bool)
for id := range sectors {
sectorSet[id] = true
}
// Scenario consistency.
sameKeys(t, "scenario.indicators", scn["indicators"].(obj), indIDs)
sameKeys(t, "scenario.economy.taxes", scn["economy"].(obj)["taxes"].(obj), taxIDs)
limit := bal["limits"].(obj)["allocMaxRatio"].(float64)
for id, raw := range sectors {
s := raw.(obj)
need, alloc := s["need"].(float64), s["alloc"].(float64)
if need <= 0 || alloc < 0 || alloc > need*limit {
t.Errorf("scenario sector %s: need=%v alloc=%v out of range", id, need, alloc)
}
}
for id, v := range scn["indicators"].(obj) {
if f := v.(float64); f < 0 || f > 100 {
t.Errorf("scenario indicator %s=%v outside 0..100", id, f)
}
}
// Balance coverage.
sameKeys(t, "balance.sectorTau", bal["sectorTau"].(obj), keys(sectors))
sameKeys(t, "balance.indicators", bal["indicators"].(obj), indIDs)
sameKeys(t, "balance.vars", bal["vars"].(obj), varIDs)
sameKeys(t, "balance.tax", bal["tax"].(obj), taxIDs)
for id, tau := range bal["sectorTau"].(obj) {
if tau.(float64) <= 0 {
t.Errorf("sectorTau.%s must be positive", id)
}
}
// Every weight points at something that exists.
dev, ind := set(devKeys), set(indIDs)
groups := []obj{bal["indicators"].(obj), bal["vars"].(obj)}
for _, g := range groups {
for id, raw := range g {
def := raw.(obj)
if def["tau"].(float64) <= 0 {
t.Errorf("%s.tau must be positive", id)
}
checkWeights(t, id, def, "sectors", sectorSet)
checkWeights(t, id, def, "alloc", sectorSet)
checkWeights(t, id, def, "econ", dev)
checkWeights(t, id, def, "ind", ind)
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"regexp"
"strings"
"testing"
)
var (
earlyEnds = []string{"revolution", "coup", "default", "impeachment"}
metricIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation", "debt", "score", "years"}
tokenPattern = regexp.MustCompile(`\{(\w+)\}`)
// Pronoun tokens each language knows how to fill (see i18n.js, tp()).
allowedTokens = map[string]map[string]bool{
"en": set([]string{"sub", "Sub", "obj", "pos", "is", "has", "was", "country"}),
"fr": set([]string{"sub", "Sub", "tonic", "e", "country"}),
}
)
func checkTokens(t *testing.T, where string, v any) {
t.Helper()
m, _ := v.(obj)
for lang, raw := range m {
s, ok := raw.(string)
if !ok {
continue
}
for _, match := range tokenPattern.FindAllStringSubmatch(s, -1) {
if !allowedTokens[lang][match[1]] {
t.Errorf("%s.%s: unknown token {%s}", where, lang, match[1])
}
}
}
}
func TestEndgame(t *testing.T) {
eg := load(t, "data/endgame.json")
bal := load(t, "data/balance.json")
// Banners: the term plus one per way of losing.
over := eg["over"].(obj)
sameKeys(t, "endgame.over", over, append([]string{"term"}, earlyEnds...))
for k, v := range over {
e := v.(obj)
if !isLocalized(e["h"]) || !isLocalized(e["d"]) {
t.Errorf("over.%s: h and d need non-empty en and fr", k)
}
}
// Urgent telegrams: one per danger zone.
alerts := eg["alerts"].(obj)
sameKeys(t, "endgame.alerts", alerts, earlyEnds)
for k, v := range alerts {
a := v.(obj)
if !isLocalized(a["title"]) || !isLocalized(a["body"]) {
t.Errorf("alerts.%s: title and body need non-empty en and fr", k)
}
}
// Historian titles.
metrics := set(metricIDs)
ids := map[string]bool{}
perEnd := map[string]int{}
titles := eg["titles"].([]any)
var lastTerm obj
for i, raw := range titles {
ti := raw.(obj)
id, _ := ti["id"].(string)
if id == "" || ids[id] {
t.Errorf("titles[%d]: missing or duplicate id %q", i, id)
}
ids[id] = true
title, _ := ti["title"].(obj)
if s, _ := title["en"].(string); strings.TrimSpace(s) == "" {
t.Errorf("%s: title.en is empty", id)
}
fr, _ := title["fr"].(obj)
for _, form := range []string{"he", "she", "they"} {
if s, _ := fr[form].(string); strings.TrimSpace(s) == "" {
t.Errorf("%s: title.fr.%s is empty", id, form)
}
}
if !isLocalized(ti["verdict"]) {
t.Errorf("%s: verdict needs non-empty en and fr", id)
}
checkTokens(t, id+".verdict", ti["verdict"])
cond, hasCond := ti["conditions"].(obj)
for _, bound := range []string{"min", "max"} {
m, _ := cond[bound].(obj)
for k := range m {
if !metrics[k] {
t.Errorf("%s.conditions.%s: unknown metric %q", id, bound, k)
}
}
}
if end, ok := ti["over"].(string); ok {
if !set(earlyEnds)[end] {
t.Errorf("%s: unknown over %q", id, end)
}
perEnd[end]++
if hasCond {
t.Errorf("%s: titles for an early end take no conditions", id)
}
} else {
lastTerm = ti
}
}
for _, end := range earlyEnds {
if perEnd[end] != 1 {
t.Errorf("early end %q needs exactly one title, has %d", end, perEnd[end])
}
}
if _, has := lastTerm["conditions"]; has || lastTerm == nil {
t.Errorf("the last term title must have no conditions so every term gets one")
}
// Balance sections the review and the game-over rules rely on.
g := bal["gameOver"].(obj)
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution"})
sc := bal["score"].(obj)
valid := set(append(append([]string{}, indIDs...), "calm"))
for k := range sc["weights"].(obj) {
if !valid[k] {
t.Errorf("score.weights: unknown key %q", k)
}
}
grades := sc["grades"].([]any)
prev := 101.0
for _, raw := range grades {
pair := raw.([]any)
min := pair[0].(float64)
if min >= prev {
t.Errorf("score.grades must be in strictly descending order of threshold")
}
prev = min
}
if prev != 0 {
t.Errorf("the last grade must have threshold 0 so every score gets a grade")
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"io/fs"
"log"
"net/http"
"os"
"countrygame/web"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Used by the compose healthcheck: the distroless image has no curl.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
resp, err := http.Get("http://127.0.0.1:" + port + "/healthz")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
root, err := fs.Sub(web.Files, ".")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.Handle("/", noCache(http.FileServer(http.FS(root))))
log.Printf("country game listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}
// noCache keeps iteration simple: data and scripts are always revalidated.
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
next.ServeHTTP(w, r)
})
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"regexp"
"sort"
"strings"
"testing"
"countrygame/web"
)
var placeholder = regexp.MustCompile(`\{(\w+)\}`)
func placeholders(s string) string {
var found []string
for _, m := range placeholder.FindAllStringSubmatch(s, -1) {
found = append(found, m[1])
}
sort.Strings(found)
return strings.Join(found, ",")
}
// English and French must offer exactly the same strings with the same {placeholders}.
func TestI18nParity(t *testing.T) {
en := load(t, "js/i18n/en.json")
fr := load(t, "js/i18n/fr.json")
for k, v := range en {
other, ok := fr[k]
if !ok {
t.Errorf("fr.json is missing %q", k)
continue
}
enText, _ := v.(string)
frText, _ := other.(string)
if strings.TrimSpace(enText) == "" || strings.TrimSpace(frText) == "" {
t.Errorf("%q has an empty string in en or fr", k)
}
if placeholders(enText) != placeholders(frText) {
t.Errorf("%q: placeholders differ (en %q, fr %q)", k, placeholders(enText), placeholders(frText))
}
}
for k := range fr {
if _, ok := en[k]; !ok {
t.Errorf("en.json is missing %q", k)
}
}
}
// Every scenario in the index exists, and every scenario file is in the index.
func TestScenarioIndex(t *testing.T) {
index := load(t, "data/scenarios/index.json")
listed := map[string]bool{}
for _, id := range index["scenarios"].([]any) {
listed[id.(string)] = true
sc := load(t, "data/scenarios/"+id.(string)+".json")
if sc["id"] != id {
t.Errorf("scenarios/%s.json has id %v", id, sc["id"])
}
if !isLocalized(sc["name"]) {
t.Errorf("scenario %s needs a name in en and fr", id)
}
}
if len(listed) == 0 {
t.Error("scenarios/index.json lists no scenario")
}
entries, err := web.Files.ReadDir("data/scenarios")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
id := strings.TrimSuffix(e.Name(), ".json")
if e.Name() != "index.json" && !listed[id] {
t.Errorf("scenarios/%s is not listed in scenarios/index.json", e.Name())
}
}
}