Files
bd2/go/internal/server/domain/world/todayquest/todayquest.go
T

209 lines
6.8 KiB
Go

// Package todayquest implements the NPC commission board from TodayQuestTable.
package todayquest
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/storage/stateio"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"time"
)
type Economy interface {
Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error)
}
type Inventory interface {
GrantOnce(ctx command.Context, _ string, _ []gamedata.BattleReward) ([]assets.Item, error)
GrantedItems(string) []assets.Item
}
type Active struct {
ID int `json:"id"`
Value int `json:"value"`
Objects []uint64 `json:"objects"`
}
type snapshot struct {
Period string `json:"period"`
Offered []int `json:"offered"`
Active map[int]Active `json:"active"`
Cleared map[int]bool `json:"cleared"`
Responses map[int][]byte `json:"responses"`
ScoreAwards map[string]uint64 `json:"score_awards"`
}
type Service struct {
store stateio.Store
design *gamedata.TodayQuestCatalog
economy Economy
inventory Inventory
now func() time.Time
unlocked func(ctx command.Context, _ int) bool
CompleteReputation func(ctx command.Context, _ string, _ int, _ uint64) ([]byte, error)
CompleteAchievement func(command.Context, string) error
}
// Request dispatch owns the account transaction; economy/inventory writes and
// this snapshot are committed together by the explicit command capability.
func Open(store stateio.Store, design *gamedata.TodayQuestCatalog, economy Economy, inventory Inventory, unlocked func(ctx command.Context, _ int) bool) (*Service, error) {
if store == nil || design == nil || len(design.Quests) == 0 || design.Limit <= 0 || design.PostCount <= 0 || economy == nil || inventory == nil || unlocked == nil {
return nil, fmt.Errorf("todayquest: invalid configuration")
}
return &Service{store: store, design: design, economy: economy, inventory: inventory, unlocked: unlocked, now: time.Now}, nil
}
func (s *Service) load(ctx command.Context) (snapshot, error) {
p, e := s.design.Reset.Period(3, s.now())
if e != nil {
return snapshot{}, e
}
raw, e := s.store.Load(ctx.State, "today_quests")
if e != nil {
return snapshot{}, e
}
st := snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: map[string]uint64{}}
if raw != nil {
if e = stateio.RequireExactJSONObject(raw, "period", "offered", "active", "cleared", "responses", "score_awards"); e != nil {
return st, e
}
if e = json.Unmarshal(raw, &st); e != nil {
return st, e
}
if st.Period == "" || st.Active == nil || st.Cleared == nil || st.Responses == nil || st.ScoreAwards == nil {
return st, fmt.Errorf("todayquest: invalid saved state")
}
}
if st.Period != p {
st = snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: st.ScoreAwards}
}
for id, a := range st.Active {
q, ok := s.design.Quests[id]
if !ok || a.ID != id || st.Cleared[id] || a.Value < 0 || a.Value > q.ConditionCount || len(a.Objects) > q.ConditionCount {
return st, fmt.Errorf("todayquest: invalid active progress")
}
seen := map[uint64]bool{}
for _, object := range a.Objects {
allowed := false
for _, v := range q.MagicValues {
if object == v {
allowed = true
}
}
if !allowed || seen[object] {
return st, fmt.Errorf("todayquest: invalid saved object")
}
seen[object] = true
}
if q.PriorID != 0 && !st.Cleared[q.PriorID] {
return st, fmt.Errorf("todayquest: missing prior clear")
}
}
for id, cleared := range st.Cleared {
if _, ok := s.design.Quests[id]; !ok || !cleared {
return st, fmt.Errorf("todayquest: invalid cleared node")
}
}
for id, b := range st.Responses {
if _, ok := s.design.Quests[id]; !ok || len(b) == 0 {
return st, fmt.Errorf("todayquest: invalid clear receipt")
}
}
// Deterministic sampling is a local server policy. Persisted offers are stable
// across restarts, with PostCount independent roots for every unlocked pack.
byPack := map[int][]int{}
for id, q := range s.design.Quests {
if q.PriorID == 0 && s.unlocked(ctx, q.PackID) {
byPack[q.PackID] = append(byPack[q.PackID], id)
}
}
existing := map[int]bool{}
for _, id := range st.Offered {
q, ok := s.design.Quests[id]
if !ok || q.PriorID != 0 {
return st, fmt.Errorf("todayquest: invalid offered root")
}
existing[q.PackID] = true
}
packs := []int{}
for p := range byPack {
packs = append(packs, p)
}
sort.Ints(packs)
for _, pack := range packs {
if existing[pack] {
continue
}
ids := byPack[pack]
sort.Slice(ids, func(i, j int) bool {
a := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[i])))
b := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[j])))
return string(a[:]) < string(b[:])
})
n := min(s.design.PostCount, len(ids))
st.Offered = append(st.Offered, ids[:n]...)
}
sort.Ints(st.Offered)
return st, nil
}
func (s *Service) save(ctx command.Context, st snapshot) error {
b, e := json.Marshal(st)
if e != nil {
return e
}
return s.store.Save(ctx.State, "today_quests", b)
}
func (s *Service) identity(st snapshot, id int, part string) string {
return fmt.Sprintf("todayquest:%s:%d:%s", st.Period, id, part)
}
func (s *Service) root(id int) int {
for s.design.Quests[id].PriorID != 0 {
id = s.design.Quests[id].PriorID
}
return id
}
func (s *Service) give(ctx command.Context, st snapshot, id int) ([]assets.Item, error) {
var rs []gamedata.BattleReward
for _, item := range s.design.Quests[id].GiveItemIDs {
rs = append(rs, gamedata.BattleReward{Type: 13, ID: item, Count: 1})
}
if len(rs) == 0 {
return nil, nil
}
key := s.identity(st, id, "give")
items, e := s.inventory.GrantOnce(ctx, key, rs)
if e == nil && len(items) == 0 {
items = s.inventory.GrantedItems(key)
}
return items, e
}
func (s *Service) secondsLeft(st snapshot) uint64 {
base, _ := time.Parse("2006-01-02", st.Period)
end := base.AddDate(0, 0, 7).Add(s.design.Reset.DailyReset - 9*time.Hour)
n := int64(end.Sub(s.now()).Seconds())
if n < 0 {
return 0
}
return uint64(n)
}
// Score projects durable commission points. These are deliberately separate
// from user experience: no current QuestClear/Notify protocol supports an Exp
// update, and the board's score label alone does not establish that meaning.
func (s *Service) Score(ctx command.Context) (uint64, error) {
st, e := s.load(ctx)
if e != nil {
return 0, e
}
var total uint64
for _, n := range st.ScoreAwards {
if n == 0 || n > 2147483647 || total > 2147483647-n {
return 0, fmt.Errorf("todayquest: invalid score awards")
}
total += n
}
return total, nil
}