fix(all): stop activity refresh loops with changed mission notifications and trace event requests
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The baseline belongs to the current account transaction, not a client session.
|
||||
// Reads and retries therefore do not replay historical progress as notifications.
|
||||
type missionNoticeKey struct {
|
||||
Schedule string
|
||||
Event, Group, Task uint64
|
||||
}
|
||||
|
||||
// visibleMissionValues is a read-only view. In particular, it must not call
|
||||
// mission(), which initializes and resets persisted progress as a side effect.
|
||||
func (s *Service) visibleMissionValues() map[missionNoticeKey]uint64 {
|
||||
out := map[missionNoticeKey]uint64{}
|
||||
for _, v := range s.taskSchedules() {
|
||||
if v.Type != 4 || !s.active(v) {
|
||||
continue
|
||||
}
|
||||
group := s.design.MissionGroups[v.ID]
|
||||
period := ""
|
||||
if group.Type == 1 {
|
||||
period = s.day()
|
||||
}
|
||||
if group.Type == 2 {
|
||||
y, w := s.now().UTC().ISOWeek()
|
||||
period = fmt.Sprintf("%d-%d", y, w)
|
||||
}
|
||||
for _, t := range s.design.Missions {
|
||||
if !s.availableTask(v, t) {
|
||||
continue
|
||||
}
|
||||
k := missionNoticeKey{scheduleKey(v), v.ID, t.Group, t.ID}
|
||||
value := uint64(0)
|
||||
if m := s.state.Missions[k.Schedule+"/"+key(t.ID)]; m != nil && m.Period == period {
|
||||
value = m.Value
|
||||
}
|
||||
out[k] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) notifyMissionChanges() ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.beforeMissions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
current := s.visibleMissionValues()
|
||||
var keys []missionNoticeKey
|
||||
for k, value := range current {
|
||||
if value != s.beforeMissions[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := keys[i], keys[j]
|
||||
if a.Event != b.Event {
|
||||
return a.Event < b.Event
|
||||
}
|
||||
if a.Group != b.Group {
|
||||
return a.Group < b.Group
|
||||
}
|
||||
if a.Task != b.Task {
|
||||
return a.Task < b.Task
|
||||
}
|
||||
return a.Schedule < b.Schedule
|
||||
})
|
||||
var out, groupMap, rows []byte
|
||||
var eventID, groupID uint64
|
||||
flushGroup := func() {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, groupID)
|
||||
entry = wire.AppendBytes(entry, 2, rows)
|
||||
groupMap = wire.AppendBytes(groupMap, 1, entry)
|
||||
rows = nil
|
||||
}
|
||||
flushEvent := func() {
|
||||
flushGroup()
|
||||
if len(groupMap) == 0 {
|
||||
return
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, eventID)
|
||||
entry = wire.AppendBytes(entry, 2, groupMap)
|
||||
out = wire.AppendBytes(out, 4, entry)
|
||||
groupMap = nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.Event != eventID {
|
||||
flushEvent()
|
||||
eventID = k.Event
|
||||
groupID = k.Group
|
||||
}
|
||||
if k.Group != groupID {
|
||||
flushGroup()
|
||||
groupID = k.Group
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, k.Task)
|
||||
entry = wire.AppendVarint(entry, 2, current[k])
|
||||
rows = wire.AppendBytes(rows, 1, entry)
|
||||
}
|
||||
flushEvent()
|
||||
// Consume only this request's baseline; the next BeforeDispatch takes a fresh
|
||||
// snapshot even when a transaction rolls back or the session changes.
|
||||
s.beforeMissions = nil
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bd2server/internal/server/world"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func notice(t *testing.T, s *Service, seq uint64) []byte {
|
||||
t.Helper()
|
||||
b, err := s.AfterDispatch("/MiniGameRouletteInfo", req(seq), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestRouletteReadsDoNotReplayHistoricalMissionNotifications(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
if err := s.RecordEvent(2, 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, session := range []string{"test", "new-session"} {
|
||||
s.BeginSession(session)
|
||||
for seq := uint64(1); seq <= 3; seq++ {
|
||||
if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(seq)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := notice(t, s, seq); len(got) != 0 {
|
||||
t.Fatalf("read notified old mission: %x", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissionNotificationContainsOnlyChangesAndAbsoluteZero(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
before, _ := json.Marshal(s.state)
|
||||
if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := json.Marshal(s.state)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("baseline initialized persistent missions")
|
||||
}
|
||||
if err := s.RecordEvent(2, 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := notice(t, s, 1)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("actual progress did not notify")
|
||||
}
|
||||
if len(notice(t, s, 1)) != 0 {
|
||||
t.Fatal("same request notified twice")
|
||||
}
|
||||
if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notice(t, s, 2)) != 0 {
|
||||
t.Fatal("subsequent read notified")
|
||||
}
|
||||
if err := s.BeforeDispatch("/reset", req(3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range s.state.Missions {
|
||||
m.Value = 0
|
||||
}
|
||||
zero := notice(t, s, 3)
|
||||
if len(zero) == 0 {
|
||||
t.Fatal("zero transition was omitted")
|
||||
}
|
||||
var value uint64 = 99
|
||||
_ = wire.Walk(zero, func(event wire.Field) error {
|
||||
groupMap, _, _ := wire.Bytes(event.Value, 2)
|
||||
return wire.Walk(groupMap, func(group wire.Field) error {
|
||||
rows, _, _ := wire.Bytes(group.Value, 2)
|
||||
return wire.Walk(rows, func(row wire.Field) error { value = scalar(row.Value, 2); return nil })
|
||||
})
|
||||
})
|
||||
if value != 0 {
|
||||
t.Fatalf("notification is not absolute zero: %d", value)
|
||||
}
|
||||
}
|
||||
|
||||
type noticeProvider struct{ count uint64 }
|
||||
|
||||
func (p *noticeProvider) Snapshot() (world.GameplayAchievementSnapshot, error) {
|
||||
return world.GameplayAchievementSnapshot{Items: map[[2]uint64]uint64{{5, 8}: p.count}}, nil
|
||||
}
|
||||
func (*noticeProvider) Events(string, []byte, []byte, world.GameplayAchievementSnapshot, world.GameplayAchievementSnapshot) ([]world.GameplayAchievementEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestObserverBatchRollbackRetryKeepsMissionDelta(t *testing.T) {
|
||||
s, _, store := setup(t)
|
||||
task := s.design.Missions[10]
|
||||
task.Type = 32
|
||||
s.design.Missions[10] = task
|
||||
p := ¬iceProvider{count: 1}
|
||||
s.AttachGameplayProvider(p)
|
||||
if err := s.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpoint, err := store.Load("eventtasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.BeforeDispatch("/batch", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.count = 2
|
||||
first := notice(t, s, 1)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("observer item gain did not notify")
|
||||
}
|
||||
// Account transaction rollback restores the domain snapshot and inventory.
|
||||
if err = store.Save("eventtasks", checkpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.state = snapshot{}
|
||||
if err = json.Unmarshal(checkpoint, &s.state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.count = 1
|
||||
if err = s.BeforeDispatch("/batch", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.count = 2
|
||||
retry := notice(t, s, 1)
|
||||
if !bytes.Equal(first, retry) {
|
||||
t.Fatalf("rolled-back retry lost delta: %x / %x", first, retry)
|
||||
}
|
||||
if err = s.BeforeDispatch("/batch", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notice(t, s, 1)) != 0 {
|
||||
t.Fatal("committed retry repeated delta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissionDeltaSeparatesPermanentGroupsAndOmitsEmptyGroups(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
r := events.NewRegistry()
|
||||
if err := r.Replace([]events.Schedule{{UID: 0, Type: 4, ID: 7, Start: 1, End: 9999999999999}, {UID: 0, Type: 4, ID: 8, Start: 1, End: 9999999999999}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.registry = r
|
||||
s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{11}}
|
||||
s.design.Missions[12] = gamedata.EventTask{ID: 12, Group: 11, Type: 99, Target: 2}
|
||||
if err := s.RecordEvent(99, 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.BeforeDispatch("/play", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RecordEvent(2, 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := notice(t, s, 1)
|
||||
count := 0
|
||||
if err := wire.Walk(got, func(f wire.Field) error {
|
||||
count++
|
||||
if f.Number != 4 || scalar(f.Value, 1) != 7 {
|
||||
t.Fatalf("unchanged group emitted: %x", f.Value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("wanted changed group only, got %d", count)
|
||||
}
|
||||
if err := s.BeforeDispatch("/read", req(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notice(t, s, 2)) != 0 {
|
||||
t.Fatal("empty event/group emitted")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
|
||||
func (s *Service) BeginSession(id string) { s.SetSession(id) }
|
||||
func (s *Service) BeforeDispatch(string, []byte) error {
|
||||
s.mu.Lock()
|
||||
s.beforeMissions = s.visibleMissionValues()
|
||||
s.mu.Unlock()
|
||||
if s.provider == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +75,7 @@ func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte,
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.Notify()
|
||||
return s.notifyMissionChanges()
|
||||
}
|
||||
|
||||
func (s *Service) CompleteSingleTargetEvent(condition uint64, unlocked func(uint64, uint64) bool) error {
|
||||
|
||||
@@ -57,6 +57,7 @@ type snapshot struct {
|
||||
type Service struct {
|
||||
provider world.GameplayAchievementProvider
|
||||
before world.GameplayAchievementSnapshot
|
||||
beforeMissions map[missionNoticeKey]uint64
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design *gamedata.EventTasksDesign
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using BD2.GameNames;
|
||||
using HarmonyLib;
|
||||
using gamfs;
|
||||
using Proto.Net;
|
||||
using UnityEngine;
|
||||
using static Bd2LoginUI.LoginRuntime;
|
||||
using static Bd2LoginUI.SessionRecovery;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Read-only method identities and schedule IDs; never packet bodies or credentials.
|
||||
internal static class EventRequestDiagnostics
|
||||
{
|
||||
private const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
private sealed class Sample { internal int Count; internal float Last, Logged; internal bool Burst; }
|
||||
private static readonly Dictionary<string, Sample> Samples = new Dictionary<string, Sample>();
|
||||
|
||||
internal static void Install(Harmony harmony)
|
||||
{
|
||||
Patch(harmony, typeof(MiniGameRoulettePacket), "SendMiniGameRouletteInfoRequest", nameof(SendPrefix), null, 2);
|
||||
Patch(harmony, typeof(MiniGameRoulettePacket), "RecvMiniGameRouletteInfoResponse", null, nameof(ReceivePostfix), 3);
|
||||
Patch(harmony, typeof(MiniGameRouletteUI), "DayReset", nameof(DayPrefix), null, 0);
|
||||
Patch(harmony, typeof(EventUI), "OpenEventUI", nameof(OpenPrefix), null, 3);
|
||||
}
|
||||
|
||||
private static void Patch(Harmony harmony, Type type, string name, string prefix, string postfix, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
MethodInfo method = type.GetGameMethod(name, All);
|
||||
if (method == null || method.GetParameters().Length != count) throw new MissingMethodException();
|
||||
harmony.Patch(method, prefix: prefix == null ? null : new HarmonyMethod(typeof(EventRequestDiagnostics), prefix),
|
||||
postfix: postfix == null ? null : new HarmonyMethod(typeof(EventRequestDiagnostics), postfix));
|
||||
}
|
||||
catch (Exception error) { Log?.LogWarning("Event diagnostic binding unavailable: target=" + type.Name + "." + name + " exception=" + error.GetType().Name); }
|
||||
}
|
||||
|
||||
private static bool Enabled => ServerRoot != null && EstablishedGameSession;
|
||||
private static bool ShouldLog(string key, out string sample)
|
||||
{
|
||||
sample = "";
|
||||
if (!Enabled) return false;
|
||||
lock (Samples)
|
||||
{
|
||||
float now = Time.realtimeSinceStartup;
|
||||
if (!Samples.TryGetValue(key, out Sample state)) Samples[key] = state = new Sample();
|
||||
float gap = state.Count == 0 ? -1 : now - state.Last;
|
||||
bool burst = state.Count > 0 && gap < 1;
|
||||
bool log = state.Count < 3 || (burst && !state.Burst) || now - state.Logged >= 10;
|
||||
state.Count++; state.Last = now; state.Burst = burst;
|
||||
if (!log) return false;
|
||||
state.Logged = now;
|
||||
sample = " count=" + state.Count + " gap_s=" + gap.ToString("F3", CultureInfo.InvariantCulture) + " burst=" + burst;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Identity(Delegate callback) => callback == null ? "none" : callback.Method.DeclaringType?.FullName + "." + callback.Method.Name;
|
||||
private static string Callers()
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (StackFrame frame in new StackTrace(false).GetFrames() ?? Array.Empty<StackFrame>())
|
||||
{
|
||||
MethodBase method = frame.GetMethod();
|
||||
if (method?.DeclaringType == null || method.DeclaringType == typeof(EventRequestDiagnostics)) continue;
|
||||
if (text.Length > 0) text.Append(" <- ");
|
||||
text.Append(method.DeclaringType.FullName).Append('.').Append(method.Name);
|
||||
if (text.Length >= 1400) break;
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string Clock()
|
||||
{
|
||||
try { return " day_remain_ms=" + SG.Schedule.DayResetStateRemain().ToString("F0", CultureInfo.InvariantCulture) + " server_utc=" + SG.Time.Now().ToString("O", CultureInfo.InvariantCulture); }
|
||||
catch { return " clock=unavailable"; }
|
||||
}
|
||||
|
||||
private static void SendPrefix(IList<int> __0, Action __1)
|
||||
{
|
||||
try { if (ShouldLog("send", out string sample)) Log?.LogInfo("Event diagnostic: roulette-send" + sample + " schedules=" + (__0 == null ? "null" : string.Join(",", __0)) + " callback=" + Identity(__1) + Clock() + " callers=" + Callers()); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void ReceivePostfix(byte[] __0, bool __result, int __2)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldLog("receive", out string sample)) return;
|
||||
MiniGameRouletteInfoResponse data = MiniGameRouletteInfoResponse.Parser.ParseFrom(__0);
|
||||
var ids = new List<string>();
|
||||
foreach (MiniGameRouletteDBInfo row in data.RouletteInfo) { if (ids.Count == 32) break; ids.Add(row.EventScheduleId.ToString(CultureInfo.InvariantCulture)); }
|
||||
Log?.LogInfo("Event diagnostic: roulette-receive" + sample + " success=" + __result + " error_code=" + __2 + " info_count=" + data.RouletteInfo.Count + " schedules=" + string.Join(",", ids));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void DayPrefix(MiniGameRouletteUI __instance)
|
||||
{
|
||||
try { if (ShouldLog("day", out string sample)) Log?.LogInfo("Event diagnostic: roulette-day-reset" + sample + " instance=" + __instance.GetInstanceID() + Clock() + " callers=" + Callers()); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void OpenPrefix(EventUI __instance, int __1, int __2)
|
||||
{
|
||||
try { if (ShouldLog("open", out string sample)) Log?.LogInfo("Event diagnostic: event-open" + sample + " instance=" + __instance.GetInstanceID() + " event_type=" + __1 + " event_id=" + __2 + Clock() + " callers=" + Callers()); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
<BD2GameVersion>2.35.10</BD2GameVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Google.Protobuf">
|
||||
<HintPath>$(BD2ManagedDir)/Google.Protobuf.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Compile Include="../Shared/ServerLoginPreferences.cs" Link="ServerLoginPreferences.cs" />
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
|
||||
|
||||
@@ -93,6 +93,7 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
|
||||
Harmony harmony = new Harmony(Guid);
|
||||
SessionDiagnostics.Install(harmony);
|
||||
EventRequestDiagnostics.Install(harmony);
|
||||
harmony.Patch(awake, postfix: new HarmonyMethod(typeof(SessionRecovery), nameof(IntroAwakePostfix)));
|
||||
HarmonyMethod maintenancePrefix = new HarmonyMethod(typeof(LoginController), nameof(SendMaintenancePrefix));
|
||||
maintenancePrefix.after = new[] { "bd2.localidentity" };
|
||||
|
||||
Reference in New Issue
Block a user