feat(currency_wars): complete systems, settlement Rank SS, damage report, camera and recovery fixes
This commit is contained in:
1 parent
c008243f11
commit
0995055296
78 files changed
+33840
-9525
No files matched your search
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")} {
|
||||
|
||||
Reference in new issue
Block a user