feat: Cards writings and balancing (M8)

This commit is contained in:
gauvainboiche
2026-10-25 18:30:43 +01:00
parent 04193e2013
commit c2103c32e7
43 changed files with 25930 additions and 397 deletions
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"testing"
)
// The M8 balance sections are read by the engine without defensive checks: a typo in a sector id
// would crash the game at runtime, so every reference is checked here.
func TestBalanceSections(t *testing.T) {
bal := load(t, "data/balance.json")
scn := load(t, "data/scenarios/default.json")
sectors := set(keys(scn["sectors"].(obj)))
for id, v := range bal["needGrowth"].(obj) {
if !sectors[id] {
t.Errorf("needGrowth: unknown sector %q", id)
}
if g, ok := v.(float64); !ok || g < 0 || g > 0.2 {
t.Errorf("needGrowth.%s must be a yearly rate between 0 and 0.2", id)
}
}
for id := range sectors {
if _, ok := bal["needGrowth"].(obj)[id]; !ok {
t.Errorf("needGrowth: missing sector %q", id)
}
}
w := bal["world"].(obj)
sameKeys(t, "balance.world", w, []string{"tau", "noise", "max", "shockChancePerDay", "shock", "boomShare"})
for k, v := range w {
if f, ok := v.(float64); !ok || f <= 0 {
t.Errorf("world.%s must be a positive number", k)
}
}
if g, ok := bal["gain"].(float64); !ok || g <= 0 {
t.Error("gain must be positive")
}
if g, ok := bal["driftScale"].(float64); !ok || g < 0 {
t.Error("driftScale must be a number >= 0")
}
if f, ok := bal["fiscal"].(obj)["growthSens"].(float64); !ok || f < 0 {
t.Error("fiscal.growthSens must be a number >= 0")
}
if c, ok := bal["gameOver"].(obj)["emergencyCooldown"].(float64); !ok || c < 1 {
t.Error("gameOver.emergencyCooldown must be at least 1 day")
}
// every indicator and economy variable definition
known := set([]string{"tau", "noise", "min", "max", "sectors", "alloc", "econ", "ind", "world", "excess", "short", "drift"})
check := func(group string, defs obj) {
for id, raw := range defs {
def := raw.(obj)
for k := range def {
if !known[k] {
t.Errorf("%s.%s: unknown field %q", group, id, k)
}
}
for _, field := range []string{"sectors", "alloc"} {
if m, ok := def[field].(obj); ok {
for sid := range m {
if !sectors[sid] {
t.Errorf("%s.%s.%s: unknown sector %q", group, id, field, sid)
}
}
}
}
for _, field := range []string{"excess", "short"} {
m, ok := def[field].(obj)
if !ok {
continue
}
for sid, pair := range m {
if !sectors[sid] {
t.Errorf("%s.%s.%s: unknown sector %q", group, id, field, sid)
}
p, ok := pair.([]any)
if !ok || len(p) != 2 {
t.Errorf("%s.%s.%s.%s must be [from, weight]", group, id, field, sid)
continue
}
from, ok1 := p[0].(float64)
weight, ok2 := p[1].(float64)
if !ok1 || !ok2 || from < 0 || from > 100 || weight < 0 {
t.Errorf("%s.%s.%s.%s: from must be 0..100 and weight >= 0", group, id, field, sid)
}
}
}
if m, ok := def["world"].(obj); ok {
for k := range m {
if k != "cycle" {
t.Errorf("%s.%s.world: unknown key %q", group, id, k)
}
}
}
if _, ok := def["drift"].(float64); def["drift"] != nil && !ok {
t.Errorf("%s.%s.drift must be a number", group, id)
}
}
}
check("indicators", bal["indicators"].(obj))
check("vars", bal["vars"].(obj))
}
+18 -3
View File
@@ -114,7 +114,10 @@ func (c *cardChecker) conditions(id string, cond obj) {
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:")])
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)
}
@@ -200,6 +203,7 @@ func TestCards(t *testing.T) {
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)
@@ -220,6 +224,7 @@ func TestCards(t *testing.T) {
continue
}
total++
perCategory[stem]++
if c.ids[id] {
t.Errorf("duplicate card id %s", id)
}
@@ -281,7 +286,17 @@ func TestCards(t *testing.T) {
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)
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)
}
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ func TestEndgame(t *testing.T) {
// 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"})
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution", "emergencyCooldown"})
sc := bal["score"].(obj)
valid := set(append(append([]string{}, indIDs...), "calm"))
for k := range sc["weights"].(obj) {