feat(currency_wars): complete systems, settlement Rank SS, damage report, camera and recovery fixes

This commit is contained in:
CAtlas0784 committed 2026-10-31 20:47:36 +07:00
1 parent c008243f11
commit 0995055296
78 files changed
+33840 -9525

No files matched your search

+160 -15
View File
@@ -53,6 +53,11 @@ func ApplyBattleReportContext(report *BattleReport, battle *goproto.SceneBattleI
return
}
report.BattleID, report.StageID = battle.BattleId, battle.StageId
if battle.EIGMKOJOEEI != nil {
report.IsCurrencyWars = true
applyCurrencyWarsBattleReportContext(report, battle)
return
}
indices := make(map[uint32]uint32, len(battle.BattleAvatarList))
metadata := make(map[uint32]*goproto.BattleAvatar, len(battle.BattleAvatarList))
for _, a := range battle.BattleAvatarList {
@@ -82,6 +87,108 @@ func ApplyBattleReportContext(report *BattleReport, battle *goproto.SceneBattleI
})
}
// In Currency Wars (GridFight), deployed avatars can exceed 4 (front + back rows).
// All active board roles are registered in grid.GridGameRoleList and their damages
// are tracked via stt.APAGMJNECCN.DOGMMGLBJIJ.
func applyCurrencyWarsBattleReportContext(report *BattleReport, battle *goproto.SceneBattleInfo) {
grid := battle.EIGMKOJOEEI
if grid == nil {
return
}
metadata := make(map[uint32]*goproto.BattleAvatar)
for _, a := range battle.BattleAvatarList {
if a != nil {
metadata[a.Id] = a
}
}
for _, a := range grid.OPHONCKPNMG {
if a != nil {
metadata[a.Id] = a
}
}
existingByAvatar := make(map[uint32]*BattleAvatarReport, len(report.Avatars))
for _, a := range report.Avatars {
if a != nil {
existingByAvatar[a.AvatarID] = a
}
}
if len(grid.GridGameRoleList) > 0 {
var cwTotalDamage float64
for _, role := range grid.GridGameRoleList {
if role == nil {
continue
}
var dmg float64
if report.CurrencyWarsRoleDamages != nil {
dmg = report.CurrencyWarsRoleDamages[role.RoleId]
} else if ex, ok := existingByAvatar[role.AvatarId]; ok {
dmg = ex.TotalDamage
}
cwTotalDamage += dmg
}
if cwTotalDamage > 0 {
report.TeamTotalDamage = cwTotalDamage
}
newAvatars := make([]*BattleAvatarReport, 0, len(grid.GridGameRoleList))
for _, role := range grid.GridGameRoleList {
if role == nil {
continue
}
avatarID := role.AvatarId
if avatarID == 0 && role.RoleId > 100 {
avatarID = role.RoleId / 100
}
ar := existingByAvatar[avatarID]
if ar == nil {
ar = &BattleAvatarReport{
AvatarID: avatarID,
AttackTypeDamage: make(map[string]float64),
AttackTypeBreakDamage: make(map[string]float64),
AttackTypeMaxDamage: make(map[string]float64),
}
}
slot := role.Pos
ar.Slot = &slot
ar.AvatarRank = role.RoleStar
if meta := metadata[avatarID]; meta != nil {
ar.AvatarLevel = meta.Level
if ar.AvatarRank == 0 && meta.Rank > 0 {
ar.AvatarRank = meta.Rank
}
}
if ar.AvatarLevel == 0 {
ar.AvatarLevel = 80
}
if report.CurrencyWarsRoleDamages != nil {
if dmg, ok := report.CurrencyWarsRoleDamages[role.RoleId]; ok {
ar.TotalDamage = dmg
}
}
if report.TeamTotalDamage > 0 {
ar.DamagePercent = (ar.TotalDamage / report.TeamTotalDamage) * 100.0
} else {
ar.DamagePercent = 0
}
newAvatars = append(newAvatars, ar)
}
sort.SliceStable(newAvatars, func(i, j int) bool {
a, b := newAvatars[i], newAvatars[j]
if a == nil || a.Slot == nil {
return false
}
if b == nil || b.Slot == nil {
return true
}
return *a.Slot < *b.Slot
})
report.Avatars = newAvatars
}
}
type BattleMonsterReport struct {
EntityID uint32 `json:"entity_id"`
MonsterID uint32 `json:"monster_id"`
@@ -93,20 +200,22 @@ type BattleMonsterReport struct {
}
type BattleReport struct {
Timestamp int64 `json:"timestamp"`
BattleID uint32 `json:"battle_id"`
StageID uint32 `json:"stage_id"`
EndStatus string `json:"end_status"` // "WIN", "FAIL", "QUIT"
TotalBattleTurns uint32 `json:"total_battle_turns"`
TotalAutoTurns uint32 `json:"total_auto_turns"`
RoundCnt uint32 `json:"round_cnt"`
CostTime float64 `json:"cost_time"`
ChallengeScore uint32 `json:"challenge_score"`
TeamTotalDamage float64 `json:"team_total_damage"`
TeamTotalHeal float64 `json:"team_total_heal"`
TeamTotalShield float64 `json:"team_total_shield"`
Avatars []*BattleAvatarReport `json:"avatars"`
Monsters []*BattleMonsterReport `json:"monsters"`
Timestamp int64 `json:"timestamp"`
BattleID uint32 `json:"battle_id"`
StageID uint32 `json:"stage_id"`
EndStatus string `json:"end_status"` // "WIN", "FAIL", "QUIT"
TotalBattleTurns uint32 `json:"total_battle_turns"`
TotalAutoTurns uint32 `json:"total_auto_turns"`
RoundCnt uint32 `json:"round_cnt"`
CostTime float64 `json:"cost_time"`
ChallengeScore uint32 `json:"challenge_score"`
TeamTotalDamage float64 `json:"team_total_damage"`
TeamTotalHeal float64 `json:"team_total_heal"`
TeamTotalShield float64 `json:"team_total_shield"`
Avatars []*BattleAvatarReport `json:"avatars"`
Monsters []*BattleMonsterReport `json:"monsters"`
IsCurrencyWars bool `json:"is_currency_wars,omitempty"`
CurrencyWarsRoleDamages map[uint32]float64 `json:"currency_wars_role_damages,omitempty"`
}
// BattleReportFromStatistics converts a protobuf BattleStatistics packet from the game client
@@ -129,6 +238,24 @@ func BattleReportFromStatistics(stt *goproto.BattleStatistics, battleID uint32,
Monsters: make([]*BattleMonsterReport, 0, len(stt.MonsterList)),
}
// In Currency Wars (GridFight), per-role combat damage is explicitly recorded in
// stt.APAGMJNECCN.DOGMMGLBJIJ (*pb.CPNCAPLOIHI).
var cwRoleDamages map[uint32]float64
var cwTotalRoleDamage float64
if stt.APAGMJNECCN != nil && len(stt.APAGMJNECCN.DOGMMGLBJIJ) > 0 {
cwRoleDamages = make(map[uint32]float64, len(stt.APAGMJNECCN.DOGMMGLBJIJ))
for _, item := range stt.APAGMJNECCN.DOGMMGLBJIJ {
if item != nil && item.RoleId != 0 {
cwRoleDamages[item.RoleId] = item.Damage
if item.Damage > 0 {
cwTotalRoleDamage += item.Damage
}
}
}
report.CurrencyWarsRoleDamages = cwRoleDamages
report.IsCurrencyWars = true
}
// The 4.6.53 schema exposes OKFALMGFHMA entries with avatar_id and damage,
// but the enclosing message and several fields remain obfuscated. Treating
// these as per-avatar damage records is an inference that still needs a raw
@@ -177,6 +304,14 @@ func BattleReportFromStatistics(stt *goproto.BattleStatistics, battleID uint32,
if damage == 0 {
damage = attackDamageByAvatar[a.Id]
}
if damage == 0 && cwRoleDamages != nil {
for rID, rDmg := range cwRoleDamages {
if rID == a.Id || rID/100 == a.Id {
damage = rDmg
break
}
}
}
primaryAvatarDamage += damage
if damage == 0 {
damage = supplementalDamageByAvatar[a.Id]
@@ -185,7 +320,9 @@ func BattleReportFromStatistics(stt *goproto.BattleStatistics, battleID uint32,
teamHeal += a.TotalHeal
teamShield += a.TotalShield
}
if primaryAvatarDamage == 0 && stt.MFOJKJGKDGD != nil && stt.MFOJKJGKDGD.TotalDamage > 0 {
if cwTotalRoleDamage > 0 {
teamDmg = cwTotalRoleDamage
} else if primaryAvatarDamage == 0 && stt.MFOJKJGKDGD != nil && stt.MFOJKJGKDGD.TotalDamage > 0 {
teamDmg = stt.MFOJKJGKDGD.TotalDamage
}
// If neither avatar aggregate source contains damage, enemy damage-taken
@@ -216,6 +353,14 @@ func BattleReportFromStatistics(stt *goproto.BattleStatistics, battleID uint32,
if damage == 0 {
damage = attackDamageByAvatar[a.Id]
}
if damage == 0 && cwRoleDamages != nil {
for rID, rDmg := range cwRoleDamages {
if rID == a.Id || rID/100 == a.Id {
damage = rDmg
break
}
}
}
if damage == 0 {
damage = supplementalDamageByAvatar[a.Id]
}
+138
View File
@@ -5,6 +5,7 @@ import (
"testing"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestBattleReportUsesActualBattleSlots(t *testing.T) {
@@ -234,3 +235,140 @@ func TestBattleReportDoesNotReplaceStandardDamageWithSupplementalAggregate(t *te
t.Fatalf("supplemental data replaced standard damage: %+v", report)
}
}
func TestCurrencyWarsBattleReportWithMoreThanFourAvatars(t *testing.T) {
// Simulated CW battle: 4 front row avatars + 4 back row avatars = 8 avatars on board.
stt := &goproto.BattleStatistics{
TotalBattleTurns: 15,
RoundCnt: 1,
CostTime: 30.0,
BattleAvatarList: []*goproto.AvatarBattleInfo{
{Id: 1213, TotalDamage: 0},
{Id: 1308, TotalDamage: 0},
{Id: 1511, TotalDamage: 0},
{Id: 1513, TotalDamage: 0},
},
APAGMJNECCN: &goproto.MGMJOPHJCNL{
DOGMMGLBJIJ: []*goproto.CPNCAPLOIHI{
{RoleId: 121302, Damage: 100000},
{RoleId: 130802, Damage: 50000},
{RoleId: 151102, Damage: 10000},
{RoleId: 151302, Damage: 40000},
{RoleId: 604202, Damage: 20000},
{RoleId: 150302, Damage: 30000},
{RoleId: 800902, Damage: 15000},
{RoleId: 150502, Damage: 35000},
},
},
}
report := BattleReportFromStatistics(stt, 101, 80211, "WIN")
if !report.IsCurrencyWars {
t.Fatal("expected IsCurrencyWars to be true")
}
if report.TeamTotalDamage != 300000 {
t.Fatalf("expected team total damage 300000, got %f", report.TeamTotalDamage)
}
battleContext := &goproto.SceneBattleInfo{
BattleId: 101,
StageId: 80211,
BattleAvatarList: []*goproto.BattleAvatar{
{Id: 1213, Level: 80, Rank: 2, Index: 0},
{Id: 1308, Level: 80, Rank: 1, Index: 1},
{Id: 1511, Level: 80, Rank: 0, Index: 2},
{Id: 1513, Level: 80, Rank: 1, Index: 3},
},
EIGMKOJOEEI: &goproto.OJCBOMCCPDB{
OPHONCKPNMG: []*goproto.BattleAvatar{
{Id: 6042, Level: 80, Rank: 1, Index: 0},
{Id: 1503, Level: 80, Rank: 0, Index: 1},
{Id: 8009, Level: 80, Rank: 6, Index: 2},
{Id: 1505, Level: 80, Rank: 0, Index: 3},
},
GridGameRoleList: []*goproto.BJGJBNJDLIK{
{RoleId: 121302, AvatarId: 1213, Pos: 1, RoleStar: 3},
{RoleId: 130802, AvatarId: 1308, Pos: 2, RoleStar: 2},
{RoleId: 151102, AvatarId: 1511, Pos: 3, RoleStar: 1},
{RoleId: 151302, AvatarId: 1513, Pos: 4, RoleStar: 2},
{RoleId: 604202, AvatarId: 6042, Pos: 5, RoleStar: 1},
{RoleId: 150302, AvatarId: 1503, Pos: 6, RoleStar: 2},
{RoleId: 800902, AvatarId: 8009, Pos: 7, RoleStar: 2},
{RoleId: 150502, AvatarId: 1505, Pos: 8, RoleStar: 3},
},
},
}
ApplyBattleReportContext(report, battleContext)
if len(report.Avatars) != 8 {
t.Fatalf("expected 8 avatars in CW battle report, got %d", len(report.Avatars))
}
expectedDamages := map[uint32]float64{
1213: 100000,
1308: 50000,
1511: 10000,
1513: 40000,
6042: 20000,
1503: 30000,
8009: 15000,
1505: 35000,
}
var totalPercent float64
for i, a := range report.Avatars {
if a.Slot == nil || *a.Slot != uint32(i+1) {
t.Fatalf("avatar %d slot expected %d, got %v", a.AvatarID, i+1, a.Slot)
}
expectedDmg := expectedDamages[a.AvatarID]
if a.TotalDamage != expectedDmg {
t.Fatalf("avatar %d damage expected %f, got %f", a.AvatarID, expectedDmg, a.TotalDamage)
}
totalPercent += a.DamagePercent
}
if totalPercent < 99.9 || totalPercent > 100.1 {
t.Fatalf("expected total percent ~100%%, got %f", totalPercent)
}
// Verify star rank
if report.Avatars[0].AvatarRank != 3 {
t.Fatalf("expected Dan Heng IL rank (star) to be 3, got %d", report.Avatars[0].AvatarRank)
}
}
func TestRealCapturedBattleResult(t *testing.T) {
path := "../../battle_diagnostics/20261031T050254.858914500Z_result_4462dff22ebf/battle_result.bin"
wire, err := os.ReadFile(path)
if err != nil {
t.Skip("real captured battle result not found on disk")
}
var req goproto.PVEBattleResultCsReq
if err := proto.Unmarshal(wire, &req); err != nil {
t.Fatal(err)
}
report := BattleReportFromStatistics(req.Stt, req.BattleId, req.StageId, "WIN")
if !report.IsCurrencyWars {
t.Fatal("expected IsCurrencyWars = true")
}
if len(report.CurrencyWarsRoleDamages) != 9 {
t.Fatalf("expected 9 role damages, got %d", len(report.CurrencyWarsRoleDamages))
}
if report.TeamTotalDamage < 115000000 {
t.Fatalf("expected team total damage > 115M, got %f", report.TeamTotalDamage)
}
battleWire, err := os.ReadFile("../../battle_diagnostics/20261031T050532.034020800Z_86babfb031c1/battle_last.bin")
if err == nil {
var battle goproto.SceneBattleInfo
if err := proto.Unmarshal(battleWire, &battle); err == nil {
ApplyBattleReportContext(report, &battle)
if len(report.Avatars) != 9 {
t.Fatalf("expected 9 avatars after applying CW battle context, got %d", len(report.Avatars))
}
// Write to battle_report.json in root so current dashboard reflects this real battle
_ = SaveBattleReport(report)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package common
import (
"encoding/json"
"fmt"
"slices"
)
type CurrencyWarsAugmentGrant struct {
Kind string `json:"kind"`
Count uint32 `json:"count"`
ID uint32 `json:"id,omitempty"`
Cost uint32 `json:"cost,omitempty"`
Star uint32 `json:"star,omitempty"`
}
type CurrencyWarsAugment struct {
ID uint32 `json:"id"`
Quality uint32 `json:"quality"`
Chapters []uint32 `json:"chapters"`
OC bool `json:"oc"`
Grants []CurrencyWarsAugmentGrant `json:"grants"`
GrantsComplete bool `json:"grants_complete"`
}
type CurrencyWarsAugmentsCatalog struct {
FormatVersion uint32 `json:"format_version"`
TargetClientVersion string `json:"target_client_version"`
TargetAssemblySHA256 string `json:"target_assembly_sha256"`
SeasonID uint32 `json:"season_id"`
Augments []CurrencyWarsAugment `json:"augments"`
byID map[uint32]CurrencyWarsAugment
}
func LoadCurrencyWarsAugments(catalog *CurrencyWarsCatalog) (*CurrencyWarsAugmentsCatalog, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsAugments.json")
if err != nil {
return nil, err
}
var out CurrencyWarsAugmentsCatalog
if err = json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if catalog != nil {
if out.FormatVersion != 1 || out.TargetClientVersion != catalog.ClientVersion || out.TargetAssemblySHA256 != catalog.Provenance.GameAssemblySHA256 {
return nil, fmt.Errorf("CW augments revision differs from catalog")
}
}
out.byID = make(map[uint32]CurrencyWarsAugment, len(out.Augments))
for _, a := range out.Augments {
out.byID[a.ID] = a
}
return &out, nil
}
func (c *CurrencyWarsAugmentsCatalog) Augment(id uint32) (CurrencyWarsAugment, bool) {
if c == nil || c.byID == nil {
return CurrencyWarsAugment{}, false
}
a, ok := c.byID[id]
return a, ok
}
func (c *CurrencyWarsAugmentsCatalog) IsEligible(aug CurrencyWarsAugment, chapter uint32, isOverclock bool) bool {
if isOverclock && !aug.OC {
return false
}
if len(aug.Chapters) > 0 && !slices.Contains(aug.Chapters, chapter) {
return false
}
return true
}
+4 -1
View File
@@ -36,7 +36,10 @@ func QueueCurrencyWarsCommand(uid, runID uint32, field string, value uint32) (st
}
func QueueCurrencyWarsGrant(uid, runID uint32, field string, value, quantity, star uint32) (string, error) {
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge") {
if field == "tb" || field == "trailblaze" {
field = "tbpower"
}
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
return "", fmt.Errorf("invalid CW command")
}
var nonce [16]byte
+5 -7
View File
@@ -53,15 +53,13 @@ func (p *CurrencyWarsCardPool) ReserveOwned(c *CurrencyWarsCatalog, id, star uin
copies := uint64(1)
for s := base.DefaultStar; s < star; s++ {
copies *= uint64(rules.Copies)
if copies > uint64(entry.Capacity) {
return fmt.Errorf("CW owned copies exceed pool capacity")
}
}
if copies > uint64(entry.Remaining) {
return fmt.Errorf("CW pool exhausted for %d", id)
if copies >= uint64(entry.Remaining) {
entry.Remaining = 0
} else {
entry.Remaining -= uint32(copies)
}
entry.Remaining -= uint32(copies)
entry.Disabled = entry.Disabled || star >= rules.OrdinaryMaxStar
entry.Disabled = entry.Disabled || star >= rules.OrdinaryMaxStar || entry.Remaining == 0
p.Roles[id] = entry
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package common
import (
"testing"
)
func TestCurrencyWarsScoreCalculations(t *testing.T) {
score, err := LoadCurrencyWarsScore(nil)
if err != nil {
t.Fatalf("load CW score: %v", err)
}
if score.WeeklyCap != 18000 {
t.Errorf("expected weekly cap 18000, got %d", score.WeeklyCap)
}
if len(score.Divisions) == 0 || len(score.Rules) == 0 {
t.Fatal("empty divisions or rules in CW score")
}
// Division 20920: ScoreRule 1, WeeklyScoreModify 100, ExpModify 180.
// Chapter 2, Section 4 won (or lost on 2-5): matches upstream test at line 11065!
weekly, expBase, expExtra := score.CalculateRunScore(20920, false, 2, 4, true)
if weekly != 13200 {
t.Errorf("expected weekly score 13200, got %d", weekly)
}
if expBase != 725 {
t.Errorf("expected exp base 725, got %d", expBase)
}
if expExtra != 580 {
t.Errorf("expected exp extra 580, got %d", expExtra)
}
// Offering Level calculation: 800 EXP per level for Offering 16 (Season 2)
level, remainder := score.OfferingLevel(1305)
if level != 1 {
t.Errorf("expected level 1, got %d", level)
}
if remainder != 505 {
t.Errorf("expected remainder 505, got %d", remainder)
}
}
func TestCurrencyWarsTraitsCatalog(t *testing.T) {
traits, err := LoadCurrencyWarsTraits(nil)
if err != nil {
t.Fatalf("load CW traits: %v", err)
}
if len(traits.Traits) < 60 {
t.Fatalf("expected at least 60 traits, got %d", len(traits.Traits))
}
// Trait 1001 (Express Cohort) has thresholds [2, 4, 6]
tr, ok := traits.Trait(1001)
if !ok {
t.Fatal("trait 1001 missing")
}
if len(tr.Thresholds) != 3 {
t.Fatalf("expected 3 thresholds, got %d", len(tr.Thresholds))
}
if layer := traits.ActiveLayer(1001, 1); layer != 0 {
t.Errorf("expected layer 0 for count 1, got %d", layer)
}
if layer := traits.ActiveLayer(1001, 3); layer != 2 {
t.Errorf("expected layer 2 for count 3, got %d", layer)
}
if layer := traits.ActiveLayer(1001, 5); layer != 4 {
t.Errorf("expected layer 4 for count 5, got %d", layer)
}
if layer := traits.ActiveLayer(1001, 6); layer != 6 {
t.Errorf("expected layer 6 for count 6, got %d", layer)
}
}
func TestCurrencyWarsAugmentsCatalog(t *testing.T) {
catalog, err := LoadCurrencyWarsAugments(nil)
if err != nil {
t.Fatalf("load CW augments: %v", err)
}
if len(catalog.Augments) != 304 {
t.Fatalf("expected 304 augments, got %d", len(catalog.Augments))
}
aug, ok := catalog.Augment(100101)
if !ok {
t.Fatal("augment 100101 missing")
}
if aug.Quality != 1 {
t.Errorf("expected quality 1, got %d", aug.Quality)
}
if !catalog.IsEligible(aug, 1, true) {
t.Error("expected augment 100101 to be eligible in chapter 1 overclock")
}
if catalog.IsEligible(aug, 3, true) {
t.Error("expected augment 100101 to not be eligible in chapter 3")
}
}
func TestCurrencyWarsNodeDropsCatalog(t *testing.T) {
drops, err := LoadCurrencyWarsNodeDrops(nil)
if err != nil {
t.Fatalf("load CW node drops: %v", err)
}
if len(drops.NodeDrops) == 0 {
t.Fatal("expected node drops rules")
}
rule900, ok := drops.NodeDrops["900"]
if !ok || rule900.Rolls != 4 {
t.Errorf("expected rule 900 with 4 rolls, got %v", rule900)
}
}
@@ -0,0 +1,35 @@
package common
import (
"encoding/json"
"fmt"
)
type CurrencyWarsNodeDrop struct {
Rolls uint32 `json:"rolls"`
Weights map[string]uint32 `json:"weights"`
}
type CurrencyWarsNodeDropsCatalog struct {
FormatVersion uint32 `json:"format_version"`
TargetClientVersion string `json:"target_client_version"`
TargetAssemblySHA256 string `json:"target_assembly_sha256"`
NodeDrops map[string]CurrencyWarsNodeDrop `json:"node_drops"`
}
func LoadCurrencyWarsNodeDrops(catalog *CurrencyWarsCatalog) (*CurrencyWarsNodeDropsCatalog, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsNodeDrops.json")
if err != nil {
return nil, err
}
var out CurrencyWarsNodeDropsCatalog
if err = json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if catalog != nil {
if out.FormatVersion != 1 || out.TargetClientVersion != catalog.ClientVersion || out.TargetAssemblySHA256 != catalog.Provenance.GameAssemblySHA256 {
return nil, fmt.Errorf("CW node drops revision differs from catalog")
}
}
return &out, nil
}
+121
View File
@@ -0,0 +1,121 @@
package common
import (
"encoding/json"
"fmt"
"strconv"
)
type CurrencyWarsWeeklyRank struct {
Rank uint32 `json:"rank"`
Score uint32 `json:"score"`
Reward uint32 `json:"reward"`
}
type CurrencyWarsScoreDivision struct {
Rule uint32 `json:"rule"`
OCRule uint32 `json:"oc_rule"`
WeeklyModify uint32 `json:"weekly_modify"`
ExpModify uint32 `json:"exp_modify"`
}
type CurrencyWarsScoreNode struct {
Chapter uint32 `json:"chapter"`
Section uint32 `json:"section"`
Weekly uint32 `json:"weekly"`
Exp uint32 `json:"exp"`
}
type CurrencyWarsOfferingLevel struct {
Level uint32 `json:"level"`
ItemCost uint32 `json:"item_cost"`
RewardID uint32 `json:"reward_id"`
}
type CurrencyWarsScore struct {
FormatVersion uint32 `json:"format_version"`
TargetClientVersion string `json:"target_client_version"`
TargetAssemblySHA256 string `json:"target_assembly_sha256"`
WeeklyCap uint32 `json:"weekly_cap"`
WeeklyRanks []CurrencyWarsWeeklyRank `json:"weekly_ranks"`
Divisions map[string]CurrencyWarsScoreDivision `json:"divisions"`
Rules map[string][]CurrencyWarsScoreNode `json:"rules"`
OfferingLevels []CurrencyWarsOfferingLevel `json:"offering_levels"`
}
func LoadCurrencyWarsScore(catalog *CurrencyWarsCatalog) (*CurrencyWarsScore, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsScore.json")
if err != nil {
return nil, err
}
var out CurrencyWarsScore
if err = json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if catalog != nil {
if out.FormatVersion != 1 || out.TargetClientVersion != catalog.ClientVersion || out.TargetAssemblySHA256 != catalog.Provenance.GameAssemblySHA256 {
return nil, fmt.Errorf("CW score revision differs from catalog")
}
}
return &out, nil
}
func (s *CurrencyWarsScore) CalculateRunScore(divisionID uint32, isOverclock bool, chapter, section uint32, isWon bool) (weeklyScore, expBase, expExtra uint32) {
if s == nil {
return 0, 0, 0
}
divKey := strconv.FormatUint(uint64(divisionID), 10)
div, exists := s.Divisions[divKey]
if !exists {
return 0, 0, 0
}
rule := div.Rule
if isOverclock && div.OCRule != 0 {
rule = div.OCRule
}
ruleKey := strconv.FormatUint(uint64(rule), 10)
nodes, exists := s.Rules[ruleKey]
if !exists || len(nodes) == 0 {
return 0, 0, 0
}
here := chapter*100 + section
var best *CurrencyWarsScoreNode
for i := range nodes {
n := &nodes[i]
at := n.Chapter*100 + n.Section
if at < here || (isWon && at == here) {
best = n
}
}
if best == nil {
return 0, 0, 0
}
weeklyMod := div.WeeklyModify
if weeklyMod == 0 {
weeklyMod = 100
}
weeklyScore = (best.Weekly * weeklyMod) / 100
expBase = best.Exp
if div.ExpModify > 100 {
expExtra = (best.Exp * (div.ExpModify - 100)) / 100
}
return weeklyScore, expBase, expExtra
}
func (s *CurrencyWarsScore) OfferingLevel(exp uint32) (level uint32, levelExp uint32) {
if s == nil || len(s.OfferingLevels) == 0 {
return 0, exp
}
left := exp
for _, row := range s.OfferingLevels {
if row.ItemCost == 0 {
continue
}
if left < row.ItemCost {
break
}
left -= row.ItemCost
level = row.Level
}
return level, left
}
+18
View File
@@ -176,3 +176,21 @@ func TestCurrencyWarsSnapshotRevisionPreventsStaleWrites(t *testing.T) {
t.Fatalf("CW commit failed to preserve unrelated persistent data: %s", strings.TrimSpace(string(persistent)))
}
}
func TestCurrencyWarsReserveOwnedMaxStar(t *testing.T) {
c := loadCurrencyWarsTestCatalog(t)
pool, err := c.NewCapturedCardPool()
if err != nil {
t.Fatal(err)
}
if err := pool.ReserveOwned(c, 130602, 4); err != nil {
t.Fatalf("ReserveOwned for 4-star role: %v", err)
}
role := pool.Roles[130602]
if role.Remaining != 0 {
t.Fatalf("expected remaining to be 0, got %d", role.Remaining)
}
if !role.Disabled {
t.Fatal("expected role to be disabled in pool")
}
}
@@ -0,0 +1,97 @@
package common
import (
"encoding/json"
"fmt"
"sort"
"strconv"
)
type CurrencyWarsTrailblazeGood struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Cost uint32 `json:"cost"`
Father uint32 `json:"father"`
Params []uint32 `json:"params"`
}
type CurrencyWarsTrailblazeCatalog struct {
Goods map[uint32]CurrencyWarsTrailblazeGood
GoodsList []CurrencyWarsTrailblazeGood
LayerParams map[uint32][]uint32
PomThreshold uint32
InitialGoodID uint32
RefreshGoodID uint32
PomGoodID uint32
}
func LoadCurrencyWarsTrailblaze() (*CurrencyWarsTrailblazeCatalog, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsTrailblaze.json")
if err != nil {
return nil, err
}
var r struct {
Goods []CurrencyWarsTrailblazeGood `json:"goods"`
LayerParams map[string][]uint32 `json:"layer_params"`
PomThreshold uint32 `json:"pom_threshold"`
InitialGood uint32 `json:"initial_goods_id"`
RefreshGood uint32 `json:"refresh_goods_id"`
PomGood uint32 `json:"pom_goods_id"`
}
if err = json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("unmarshal CurrencyWarsTrailblaze: %w", err)
}
cat := &CurrencyWarsTrailblazeCatalog{
Goods: make(map[uint32]CurrencyWarsTrailblazeGood, len(r.Goods)),
GoodsList: r.Goods,
LayerParams: make(map[uint32][]uint32, len(r.LayerParams)),
PomThreshold: r.PomThreshold,
InitialGoodID: r.InitialGood,
RefreshGoodID: r.RefreshGood,
PomGoodID: r.PomGood,
}
if cat.InitialGoodID == 0 {
cat.InitialGoodID = 101
}
if cat.RefreshGoodID == 0 {
cat.RefreshGoodID = 999
}
if cat.PomGoodID == 0 {
cat.PomGoodID = 507
}
if cat.PomThreshold == 0 {
cat.PomThreshold = 300
}
for _, g := range r.Goods {
cat.Goods[g.ID] = g
}
for k, v := range r.LayerParams {
layer, err := strconv.ParseUint(k, 10, 32)
if err == nil {
cat.LayerParams[uint32(layer)] = v
}
}
sort.Slice(cat.GoodsList, func(i, j int) bool {
return cat.GoodsList[i].ID < cat.GoodsList[j].ID
})
return cat, nil
}
func (c *CurrencyWarsTrailblazeCatalog) Tier(layer uint32) uint32 {
if params, ok := c.LayerParams[layer]; ok && len(params) > 0 {
return params[0]
}
return 1
}
func (c *CurrencyWarsTrailblazeCatalog) Income(layer uint32) uint32 {
if params, ok := c.LayerParams[layer]; ok && len(params) > 1 {
return params[1]
}
return 0
}
+74
View File
@@ -0,0 +1,74 @@
package common
import (
"encoding/json"
"fmt"
)
type CurrencyWarsTrait struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Thresholds []uint32 `json:"thresholds"`
ActivationType uint32 `json:"activation_type"`
TraitType uint32 `json:"trait_type"`
SeasonID uint32 `json:"season_id"`
}
type CurrencyWarsTraitsCatalog struct {
FormatVersion uint32 `json:"format_version"`
TargetClientVersion string `json:"target_client_version"`
TargetAssemblySHA256 string `json:"target_assembly_sha256"`
Traits []CurrencyWarsTrait `json:"traits"`
Effects map[string]int32 `json:"effects"`
Constants map[string]interface{} `json:"constants"`
byID map[uint32]CurrencyWarsTrait
}
func LoadCurrencyWarsTraits(catalog *CurrencyWarsCatalog) (*CurrencyWarsTraitsCatalog, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsTraits.json")
if err != nil {
return nil, err
}
var out CurrencyWarsTraitsCatalog
if err = json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if catalog != nil {
if out.FormatVersion != 1 || out.TargetClientVersion != catalog.ClientVersion || out.TargetAssemblySHA256 != catalog.Provenance.GameAssemblySHA256 {
return nil, fmt.Errorf("CW traits revision differs from catalog")
}
}
out.byID = make(map[uint32]CurrencyWarsTrait, len(out.Traits))
for _, t := range out.Traits {
out.byID[t.ID] = t
}
return &out, nil
}
func (c *CurrencyWarsTraitsCatalog) Trait(id uint32) (CurrencyWarsTrait, bool) {
if c == nil || c.byID == nil {
return CurrencyWarsTrait{}, false
}
t, ok := c.byID[id]
return t, ok
}
func (c *CurrencyWarsTraitsCatalog) ActiveLayer(traitID, count uint32) uint32 {
t, ok := c.Trait(traitID)
if !ok {
return 0
}
exact := t.ActivationType == 3
var layer uint32
for _, need := range t.Thresholds {
valid := count >= need
if exact {
valid = count == need
}
if valid && need > layer {
layer = need
}
}
return layer
}
+204
View File
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Adapted from Currency-War-SR by xaodev (2026); see third_party/currency-war-sr.
package common
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
type CurrencyWarsBranchRule struct {
ID uint32 `json:"id"`
Family uint32 `json:"family"`
Quality uint32 `json:"quality"`
PerformLevel uint32 `json:"perform_level"`
}
type CurrencyWarsXaodev struct {
Format uint32 `json:"format_version"`
Client string `json:"target_client_version"`
Assembly string `json:"target_assembly_sha256"`
Provenance struct {
Commit string `json:"commit"`
Inputs map[string]string `json:"inputs_sha256"`
Native struct {
Assembly string `json:"game_assembly_sha256"`
} `json:"native_source"`
} `json:"provenance"`
NodeRules []CurrencyWarsBranchRule `json:"node_rules"`
DiffAdd []struct{ Rule, Quality, Add uint32 } `json:"diff_add"`
Difficulty []struct{ Chapter, Level uint32 } `json:"difficulty_levels"`
Divisions []struct{ DivisionID, EnemyDifficultyLevel, BinaryNodeDiffAddRule uint32 } `json:"divisions"`
Policy struct {
Source string `json:"source"`
MonsterCount uint32 `json:"monster_count"`
MonsterTier uint32 `json:"monster_tier"`
StarCap uint32 `json:"star_cap"`
NormalQuality uint32 `json:"normal_quality"`
PowerfulQualities []uint32 `json:"powerful_qualities"`
RewardPolicy string `json:"reward_policy"`
ExtraRewards bool `json:"extra_rewards_supported"`
} `json:"generation_policy"`
}
func LoadCurrencyWarsXaodev(c *CurrencyWarsCatalog) (*CurrencyWarsXaodev, error) {
raw, err := ReadResourceFile("resources/CurrencyWarsXaodev.json")
if err != nil {
return nil, err
}
v := new(CurrencyWarsXaodev)
if err = json.Unmarshal(raw, v); err != nil {
return nil, err
}
if c == nil || v.Format != 1 || v.Client != c.ClientVersion || v.Assembly != c.Provenance.GameAssemblySHA256 || v.Provenance.Native.Assembly != v.Assembly {
return nil, fmt.Errorf("CW xaodev resource revision differs")
}
commit, err := hex.DecodeString(v.Provenance.Commit)
if err != nil || len(commit) != 20 || len(v.Provenance.Inputs) == 0 {
return nil, fmt.Errorf("CW xaodev source revision missing")
}
for _, digest := range v.Provenance.Inputs {
h, err := hex.DecodeString(digest)
if err != nil || len(h) != sha256.Size {
return nil, fmt.Errorf("CW xaodev source digest invalid")
}
}
if v.Policy.Source == "" || v.Policy.MonsterCount == 0 || v.Policy.MonsterCount > uint32(^uint16(0)) || v.Policy.MonsterTier == 0 || v.Policy.StarCap == 0 || v.Policy.NormalQuality == 0 || len(v.Policy.PowerfulQualities) == 0 || v.Policy.RewardPolicy != "preview_base_gold_already_paid_by_settlement" || v.Policy.ExtraRewards {
return nil, fmt.Errorf("CW xaodev generation policy unsupported")
}
qualities := map[uint32]bool{v.Policy.NormalQuality: true}
for _, q := range v.Policy.PowerfulQualities {
if q <= v.Policy.NormalQuality || qualities[q] {
return nil, fmt.Errorf("CW xaodev powerful quality invalid")
}
qualities[q] = true
}
seen := map[uint32]bool{}
keys := map[[2]uint32]bool{}
for _, r := range v.NodeRules {
key := [2]uint32{r.Family, r.Quality}
if r.ID == 0 || r.Quality == 0 || seen[r.ID] || keys[key] {
return nil, fmt.Errorf("CW xaodev branch rules duplicate or invalid")
}
seen[r.ID], keys[key] = true, true
}
if len(seen) == 0 {
return nil, fmt.Errorf("CW xaodev branch rules empty")
}
seen = map[uint32]bool{}
for _, d := range v.Divisions {
s, err := c.CapturedStage(d.DivisionID)
if err != nil || seen[d.DivisionID] || s.EnemyHardLevel != d.EnemyDifficultyLevel {
return nil, fmt.Errorf("CW xaodev division differs from catalog")
}
seen[d.DivisionID] = true
}
if len(seen) != len(c.CapturedStages) {
return nil, fmt.Errorf("CW xaodev divisions incomplete")
}
keys = map[[2]uint32]bool{}
for _, r := range v.DiffAdd {
key := [2]uint32{r.Rule, r.Quality}
if r.Rule == 0 || r.Quality == 0 || keys[key] {
return nil, fmt.Errorf("CW xaodev difficulty rule duplicate or invalid")
}
keys[key] = true
}
keys = map[[2]uint32]bool{}
for _, r := range v.Difficulty {
key := [2]uint32{r.Chapter, r.Level}
if r.Chapter == 0 || keys[key] {
return nil, fmt.Errorf("CW xaodev difficulty level duplicate or invalid")
}
keys[key] = true
}
if len(keys) == 0 {
return nil, fmt.Errorf("CW xaodev difficulty levels empty")
}
return v, nil
}
func (v *CurrencyWarsXaodev) Branch(family, quality uint32) (CurrencyWarsBranchRule, error) {
for _, row := range v.NodeRules {
if row.Family == family && row.Quality == quality {
return row, nil
}
}
return CurrencyWarsBranchRule{}, fmt.Errorf("CW xaodev branch family=%d quality=%d missing", family, quality)
}
func (v *CurrencyWarsXaodev) BranchDifficulty(division, option uint32) (uint32, error) {
var rule, quality uint32
var foundDivision, foundOption bool
for _, d := range v.Divisions {
if d.DivisionID == division {
rule = d.BinaryNodeDiffAddRule
foundDivision = true
break
}
}
if !foundDivision {
return 0, fmt.Errorf("CW xaodev division %d is not mapped", division)
}
for _, r := range v.NodeRules {
if r.ID == option {
quality = r.Quality
foundOption = true
break
}
}
if !foundOption {
return 0, fmt.Errorf("CW xaodev branch option %d is not mapped", option)
}
if rule == 0 {
return 0, nil
}
for _, r := range v.DiffAdd {
if r.Rule == rule && r.Quality == quality {
return r.Add, nil
}
}
return 0, nil
}
func (v *CurrencyWarsXaodev) SupportsDifficulty(chapter, level uint32) bool {
for _, r := range v.Difficulty {
if r.Chapter == chapter && r.Level == level {
return true
}
}
return false
}
// Node-specific gain follows xaodev roundExp, using our current Client constants.
// Missing constants/chapters are errors; no fixed default is substituted.
func (c *CurrencyWarsCatalog) BattleNodeExperience(overclock bool, chapter uint32, kind string) (uint32, error) {
if chapter == 0 {
return 0, fmt.Errorf("CW EXP chapter missing")
}
name := "GridFight_GainExpWhenWaveEnd"
if kind == "Boss" {
name = "GridFight_GainExpWhenBossWaveEnd"
}
if overclock {
name = "GridFight_OCGainExpWhenWaveEndList"
if kind == "Boss" {
name = "GridFight_OCGainExpWhenBossWaveEndList"
}
values, err := c.IntegerArrayRule(name)
if err != nil {
return 0, err
}
if uint64(chapter) > uint64(len(values)) {
return 0, fmt.Errorf("CW EXP chapter outside native rule")
}
return values[chapter-1], nil
}
gain, err := c.IntegerRule(name)
if err != nil || gain < 0 || uint64(gain) > uint64(^uint32(0)) {
return 0, fmt.Errorf("CW EXP gain rule invalid: %s", name)
}
return uint32(gain), nil
}
@@ -0,0 +1,64 @@
package common
import "testing"
func TestCurrencyWarsXaodevRulesMatchCapturedDivisions(t *testing.T) {
c := loadCurrencyWarsTestCatalog(t)
v, err := LoadCurrencyWarsXaodev(c)
if err != nil {
t.Fatal(err)
}
for _, d := range v.Divisions {
for _, r := range v.NodeRules {
add, err := v.BranchDifficulty(d.DivisionID, r.ID)
if err != nil {
t.Fatalf("division %d option %d: %v", d.DivisionID, r.ID, err)
}
if d.BinaryNodeDiffAddRule == 0 && add != 0 {
t.Fatalf("division %d with zero rule adds difficulty %d", d.DivisionID, add)
}
if r.Quality == v.Policy.NormalQuality && add != 0 {
t.Fatal("normal branch adds difficulty")
}
}
}
if _, err := v.BranchDifficulty(0, 0); err == nil {
t.Fatal("unknown rule accepted")
}
changed := *c
changed.ClientVersion += "-mismatch"
if _, err := LoadCurrencyWarsXaodev(&changed); err == nil {
t.Fatal("revision mismatch accepted")
}
}
func TestCurrencyWarsBattleNodeExperienceUsesModeChapterAndBoss(t *testing.T) {
c := loadCurrencyWarsTestCatalog(t)
for _, boss := range []bool{false, true} {
kind, scalar, array := "Monster", "GridFight_GainExpWhenWaveEnd", "GridFight_OCGainExpWhenWaveEndList"
if boss {
kind, scalar, array = "Boss", "GridFight_GainExpWhenBossWaveEnd", "GridFight_OCGainExpWhenBossWaveEndList"
}
want, err := c.IntegerRule(scalar)
if err != nil {
t.Fatal(err)
}
got, err := c.BattleNodeExperience(false, 1, kind)
if err != nil || uint64(got) != uint64(want) {
t.Fatalf("normal %s: gain=%d err=%v", kind, got, err)
}
values, err := c.IntegerArrayRule(array)
if err != nil {
t.Fatal(err)
}
for index, want := range values {
got, err := c.BattleNodeExperience(true, uint32(index+1), kind)
if err != nil || got != want {
t.Fatalf("OC %s chapter %d: gain=%d err=%v", kind, index+1, got, err)
}
}
if _, err := c.BattleNodeExperience(true, uint32(len(values)+1), kind); err == nil {
t.Fatal("missing chapter accepted")
}
}
}
+9
View File
@@ -65,6 +65,15 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
path := os.Getenv("GAC_CONFIG")
if path == "" {
path = "server.json"
if _, err := os.Stat(path); os.IsNotExist(err) {
for _, prefix := range []string{"..", "../..", "../../.."} {
candidate := filepath.Join(prefix, "server.json")
if _, err := os.Stat(candidate); err == nil {
path = candidate
break
}
}
}
}
var cfg RuntimeConfig
for i, filename := range []string{path, filepath.Join(filepath.Dir(path), strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))+".local.json")} {