DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,358 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const EventPVFPath = "event/chn_event/chn_joust.evt"
|
||||
|
||||
var ErrPVFMalformed = errors.New("joust PVF is malformed")
|
||||
|
||||
// Rider is one immutable knight definition copied from the active Script.pvf.
|
||||
// Win and Loss retain the four seven-value client battle timeline profiles;
|
||||
// they are not the historical win/loss counters in op1241.
|
||||
type Rider struct {
|
||||
ID byte
|
||||
AttackType byte
|
||||
Name string
|
||||
Win []uint16
|
||||
Loss []uint16
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
minimumLevel int
|
||||
maximumBet uint32
|
||||
rewardItemID int64
|
||||
participationItemID int64
|
||||
materialItemIDs []int64
|
||||
riders []Rider
|
||||
}
|
||||
|
||||
func LoadCatalog(ctx context.Context, source dnfpvf.Source) (*Catalog, error) {
|
||||
if source == nil {
|
||||
return nil, dnfpvf.ErrSourceRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := source.ReadText(EventPVFPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read joust PVF %q: %w", EventPVFPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(EventPVFPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joust PVF %q: %w", EventPVFPath, err)
|
||||
}
|
||||
|
||||
minimumLevel, err := joustSingletonInt(document, "min level")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maximumBet, err := joustSingletonInt(document, "max betting")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardItemID, err := joustSingletonInt(document, "reward")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
participationItemID, err := joustSingletonInt(document, "betting reward")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
materials, err := joustIntegerSection(document, "material")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
riders, err := joustRiders(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if minimumLevel != MinimumLevel || maximumBet != MaximumBetPerRound ||
|
||||
rewardItemID != PermanentCrystalID || participationItemID != ParticipationItemID ||
|
||||
len(materials) != 2 || materials[0] != PermanentCrystalID || materials[1] != EventCrystalID {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: incompatible rules min=%d max=%d reward=%d betting_reward=%d materials=%v",
|
||||
ErrPVFMalformed,
|
||||
minimumLevel,
|
||||
maximumBet,
|
||||
rewardItemID,
|
||||
participationItemID,
|
||||
materials,
|
||||
)
|
||||
}
|
||||
if len(riders) < OpeningKnightCount {
|
||||
return nil, fmt.Errorf("%w: riders=%d want-at-least=%d", ErrPVFMalformed, len(riders), OpeningKnightCount)
|
||||
}
|
||||
return &Catalog{
|
||||
minimumLevel: int(minimumLevel),
|
||||
maximumBet: uint32(maximumBet),
|
||||
rewardItemID: rewardItemID,
|
||||
participationItemID: participationItemID,
|
||||
materialItemIDs: append([]int64(nil), materials...),
|
||||
riders: cloneRiders(riders),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Riders() []Rider {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneRiders(c.riders)
|
||||
}
|
||||
|
||||
func joustSingletonInt(document *dnfpvf.Document, name string) (int64, error) {
|
||||
values, err := joustIntegerSection(document, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return 0, fmt.Errorf("%w: path=%s section=[%s] values=%d want=1", ErrPVFMalformed, EventPVFPath, name, len(values))
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func joustIntegerSection(document *dnfpvf.Document, name string) ([]int64, error) {
|
||||
if document == nil {
|
||||
return nil, fmt.Errorf("%w: nil document", ErrPVFMalformed)
|
||||
}
|
||||
count := 0
|
||||
for _, section := range document.Sections {
|
||||
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, fmt.Errorf("%w: path=%s section=[%s] count=%d want=1", ErrPVFMalformed, EventPVFPath, name, count)
|
||||
}
|
||||
tokens, ok := document.Section(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: path=%s section=[%s] unreadable", ErrPVFMalformed, EventPVFPath, name)
|
||||
}
|
||||
values := make([]int64, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenInt {
|
||||
return nil, fmt.Errorf("%w: path=%s section=[%s] token=%q is not an integer", ErrPVFMalformed, EventPVFPath, name, token.Raw)
|
||||
}
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
type joustRiderBuilder struct {
|
||||
rider Rider
|
||||
indexSet bool
|
||||
attackTypeSet bool
|
||||
nameSet bool
|
||||
activeField string
|
||||
}
|
||||
|
||||
func joustRiders(document *dnfpvf.Document) ([]Rider, error) {
|
||||
if document == nil {
|
||||
return nil, fmt.Errorf("%w: nil document", ErrPVFMalformed)
|
||||
}
|
||||
riders := make([]Rider, 0, 12)
|
||||
seen := make(map[byte]struct{}, 12)
|
||||
var current *joustRiderBuilder
|
||||
for _, token := range document.Tokens {
|
||||
if token.Kind == dnfpvf.TokenSection {
|
||||
section := strings.ToLower(strings.TrimSpace(token.Value))
|
||||
switch section {
|
||||
case "knight":
|
||||
if current != nil {
|
||||
return nil, fmt.Errorf("%w: nested [knight] at line=%d", ErrPVFMalformed, token.Line)
|
||||
}
|
||||
current = &joustRiderBuilder{}
|
||||
case "/knight":
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("%w: unmatched [/knight] at line=%d", ErrPVFMalformed, token.Line)
|
||||
}
|
||||
if err := current.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, duplicate := seen[current.rider.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%w: duplicate rider index=%d", ErrPVFMalformed, current.rider.ID)
|
||||
}
|
||||
seen[current.rider.ID] = struct{}{}
|
||||
riders = append(riders, cloneRider(current.rider))
|
||||
current = nil
|
||||
default:
|
||||
if current != nil {
|
||||
current.activeField = section
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if err := current.consume(token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
return nil, fmt.Errorf("%w: unterminated [knight]", ErrPVFMalformed)
|
||||
}
|
||||
return riders, nil
|
||||
}
|
||||
|
||||
func (b *joustRiderBuilder) consume(token dnfpvf.Token) error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("%w: nil rider builder", ErrPVFMalformed)
|
||||
}
|
||||
switch b.activeField {
|
||||
case "index":
|
||||
value, err := joustByteToken(token, "index")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.indexSet {
|
||||
return fmt.Errorf("%w: duplicate rider index value", ErrPVFMalformed)
|
||||
}
|
||||
b.rider.ID = value
|
||||
b.indexSet = true
|
||||
case "attack type":
|
||||
value, err := joustByteToken(token, "attack type")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.attackTypeSet {
|
||||
return fmt.Errorf("%w: duplicate rider attack type", ErrPVFMalformed)
|
||||
}
|
||||
b.rider.AttackType = value
|
||||
b.attackTypeSet = true
|
||||
case "knight name":
|
||||
if token.Kind != dnfpvf.TokenString || strings.TrimSpace(token.Value) == "" || b.nameSet {
|
||||
return fmt.Errorf("%w: invalid rider name token=%q", ErrPVFMalformed, token.Raw)
|
||||
}
|
||||
b.rider.Name = token.Value
|
||||
b.nameSet = true
|
||||
case "win":
|
||||
value, err := joustUint16Token(token, "win")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.rider.Win = append(b.rider.Win, value)
|
||||
case "loss":
|
||||
value, err := joustUint16Token(token, "loss")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.rider.Loss = append(b.rider.Loss, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *joustRiderBuilder) validate() error {
|
||||
if b == nil || !b.indexSet || !b.attackTypeSet || !b.nameSet || len(b.rider.Win) != 28 || len(b.rider.Loss) != 28 {
|
||||
return fmt.Errorf(
|
||||
"%w: rider index_set=%t attack_set=%t name_set=%t win=%d loss=%d",
|
||||
ErrPVFMalformed,
|
||||
b != nil && b.indexSet,
|
||||
b != nil && b.attackTypeSet,
|
||||
b != nil && b.nameSet,
|
||||
func() int {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
return len(b.rider.Win)
|
||||
}(),
|
||||
func() int {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
return len(b.rider.Loss)
|
||||
}(),
|
||||
)
|
||||
}
|
||||
if _, ok := attackTypeTableOffset(b.rider.AttackType); !ok {
|
||||
return fmt.Errorf("%w: rider=%d unsupported attack type=%d", ErrPVFMalformed, b.rider.ID, b.rider.AttackType)
|
||||
}
|
||||
if err := validateBattleTimelineProfiles(b.rider.ID, "win", b.rider.Win); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBattleTimelineProfiles(b.rider.ID, "loss", b.rider.Loss); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBattleTimelineProfiles(rider byte, field string, values []uint16) error {
|
||||
for group := 0; group < 4; group++ {
|
||||
previous := uint16(0)
|
||||
for position := 0; position < 7; position++ {
|
||||
value := values[group*7+position]
|
||||
if value < previous || value > 100 {
|
||||
return fmt.Errorf("%w: rider=%d %s group=%d position=%d timeline=%d previous=%d", ErrPVFMalformed, rider, field, group, position, value, previous)
|
||||
}
|
||||
previous = value
|
||||
}
|
||||
if previous == 0 {
|
||||
return fmt.Errorf("%w: rider=%d %s group=%d terminal timeline is zero", ErrPVFMalformed, rider, field, group)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attackTypeTableOffset(attackType byte) (int, bool) {
|
||||
switch attackType {
|
||||
case 0:
|
||||
return 0, true
|
||||
case 1:
|
||||
return 7, true
|
||||
case 27:
|
||||
return 14, true
|
||||
case 28:
|
||||
return 21, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Catalog) rider(id byte) (Rider, bool) {
|
||||
if c == nil {
|
||||
return Rider{}, false
|
||||
}
|
||||
for _, rider := range c.riders {
|
||||
if rider.ID == id {
|
||||
return cloneRider(rider), true
|
||||
}
|
||||
}
|
||||
return Rider{}, false
|
||||
}
|
||||
|
||||
func joustByteToken(token dnfpvf.Token, field string) (byte, error) {
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > 255 {
|
||||
return 0, fmt.Errorf("%w: rider %s token=%q", ErrPVFMalformed, field, token.Raw)
|
||||
}
|
||||
return byte(token.Int), nil
|
||||
}
|
||||
|
||||
func joustUint16Token(token dnfpvf.Token, field string) (uint16, error) {
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > 65535 {
|
||||
return 0, fmt.Errorf("%w: rider %s token=%q", ErrPVFMalformed, field, token.Raw)
|
||||
}
|
||||
return uint16(token.Int), nil
|
||||
}
|
||||
|
||||
func cloneRiders(source []Rider) []Rider {
|
||||
out := make([]Rider, len(source))
|
||||
for index := range source {
|
||||
out[index] = cloneRider(source[index])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRider(source Rider) Rider {
|
||||
source.Win = append([]uint16(nil), source.Win...)
|
||||
source.Loss = append([]uint16(nil), source.Loss...)
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealPVFJoustCatalogMatchesCurrentClientRiders(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the live joust catalog")
|
||||
}
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := LoadCatalog(context.Background(), archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
riders := catalog.Riders()
|
||||
if len(riders) != 12 {
|
||||
t.Fatalf("riders=%d want=12", len(riders))
|
||||
}
|
||||
ids := make([]byte, len(riders))
|
||||
attackTypes := make([]byte, len(riders))
|
||||
names := make([]string, len(riders))
|
||||
for index, rider := range riders {
|
||||
ids[index] = rider.ID
|
||||
attackTypes[index] = rider.AttackType
|
||||
names[index] = rider.Name
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}) ||
|
||||
!reflect.DeepEqual(attackTypes, []byte{1, 1, 0, 0, 1, 27, 27, 0, 1, 0, 28, 28}) ||
|
||||
!reflect.DeepEqual(names, []string{"爱德华", "理查德", "罗兰", "贝奥武夫", "莱奥", "伊萨尔", "吉利特", "席恩", "湖上骑士兰斯洛特", "无双飞将吕布", "骷髅骑士", "机械骑士格弗雷"}) {
|
||||
t.Fatalf("ids=%v attack_types=%v names=%v", ids, attackTypes, names)
|
||||
}
|
||||
tournament, err := catalog.TournamentFor(4321)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tournament.Champion() > 11 {
|
||||
t.Fatalf("champion=%d matches=%+v", tournament.Champion(), tournament.Matches)
|
||||
}
|
||||
crashRound, err := catalog.TournamentFor(51420)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if crashRound.Matches[0].Winner == 8 || crashRound.Matches[0].Loser == 8 {
|
||||
t.Fatalf("round 51420 included dungeon lord Lancelot: %+v", crashRound.Matches[0])
|
||||
}
|
||||
for index, match := range crashRound.Matches {
|
||||
winner, winnerOK := catalog.rider(match.Winner)
|
||||
loser, loserOK := catalog.rider(match.Loser)
|
||||
winnerOffset, winnerTypeOK := attackTypeTableOffset(loser.AttackType)
|
||||
loserOffset, loserTypeOK := attackTypeTableOffset(winner.AttackType)
|
||||
if !winnerOK || !loserOK || !winnerTypeOK || !loserTypeOK ||
|
||||
match.WinnerAction != byte(winnerOffset/7) || match.LoserAction != byte(loserOffset/7) {
|
||||
t.Fatalf("round 51420 match[%d]=%+v winner=%+v loser=%+v", index, match, winner, loser)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type joustCatalogTestSource map[string]string
|
||||
|
||||
func (s joustCatalogTestSource) ReadText(path string) (string, error) {
|
||||
text, ok := s[path]
|
||||
if !ok {
|
||||
return "", dnfpvf.ErrDocNotFound
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func TestLoadCatalogReadsRidersAndBattleTables(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
riders := catalog.Riders()
|
||||
if len(riders) != 12 || riders[0].ID != 0 || riders[0].AttackType != 0 || riders[0].Name != "骑士0" ||
|
||||
len(riders[0].Win) != 28 || len(riders[0].Loss) != 28 || riders[11].ID != 11 {
|
||||
t.Fatalf("riders=%+v", riders)
|
||||
}
|
||||
riders[0].Win[0] = 999
|
||||
if catalog.Riders()[0].Win[0] == 999 {
|
||||
t.Fatal("catalog rider slices were not detached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCatalogRejectsTooFewRiders(t *testing.T) {
|
||||
_, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(7)})
|
||||
if err == nil || !strings.Contains(err.Error(), "want-at-least=8") {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testJoustPVF(riderCount int) string {
|
||||
var text strings.Builder
|
||||
text.WriteString("[min level]\n50\n[max betting]\n10000\n[reward]\n490005585\n")
|
||||
text.WriteString("[betting reward]\n490005593\n[material]\n490005585 490005586\n[/material]\n[knight info]\n")
|
||||
for index := 0; index < riderCount; index++ {
|
||||
attackType := index % 2
|
||||
if index >= 10 {
|
||||
attackType = 28
|
||||
}
|
||||
fmt.Fprintf(&text, "[knight]\n[index]\n%d\n[attack type]\n%d\n[knight name]\n`骑士%d`\n", index, attackType, index)
|
||||
text.WriteString("[win]\n")
|
||||
for value := 0; value < 28; value++ {
|
||||
fmt.Fprintf(&text, "%d ", []int{10, 25, 40, 55, 70, 85, 100}[value%7])
|
||||
}
|
||||
text.WriteString("\n[/win]\n[loss]\n")
|
||||
for value := 0; value < 28; value++ {
|
||||
fmt.Fprintf(&text, "%d ", []int{10, 25, 40, 55, 70, 85, 100}[value%7])
|
||||
}
|
||||
text.WriteString("\n[/loss]\n[/knight]\n")
|
||||
}
|
||||
text.WriteString("[/knight info]\n")
|
||||
return text.String()
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
MinimumLevel = 50
|
||||
MaximumBetPerRound = 10_000
|
||||
OpeningKnightCount = 8
|
||||
PermanentCrystalID = 490005585
|
||||
EventCrystalID = 490005586
|
||||
ParticipationItemID = 490005593
|
||||
|
||||
RoundStat = "joust_round"
|
||||
KnightStat = "joust_knight"
|
||||
AmountStat = "joust_amount"
|
||||
SourceItemIDStat = "joust_source_item_id"
|
||||
PendingStat = "joust_pending"
|
||||
LastBetUnixStat = "joust_last_bet_unix"
|
||||
WinnerStat = "joust_winner"
|
||||
PayoutStat = "joust_payout"
|
||||
SettledUnixStat = "joust_settled_unix"
|
||||
|
||||
// KnightAmountStatPrefix stores the current round's per-knight support.
|
||||
// AmountStat remains the total sent to the current client in op1242, while
|
||||
// these entries preserve split support across all active riders.
|
||||
KnightAmountStatPrefix = "joust_knight_amount_"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("joust owner unavailable")
|
||||
ErrRequestInvalid = errors.New("joust betting request invalid")
|
||||
ErrCharacterNotFound = errors.New("joust character not found")
|
||||
ErrAccountMismatch = errors.New("joust character account mismatch")
|
||||
ErrLevelTooLow = errors.New("joust character level is too low")
|
||||
ErrInventoryNotFound = errors.New("joust inventory not found")
|
||||
ErrCrystalMissing = errors.New("joust crystal is missing")
|
||||
ErrCrystalInvalid = errors.New("joust source is not a supported crystal")
|
||||
ErrCrystalInsufficient = errors.New("joust crystal count is insufficient")
|
||||
ErrCrystalChanged = errors.New("joust source crystal cannot change within one round")
|
||||
ErrKnightUnavailable = errors.New("joust knight is not in the active round")
|
||||
ErrKnightChanged = errors.New("joust knight cannot change within one round")
|
||||
ErrRoundLimit = errors.New("joust round betting limit exceeded")
|
||||
ErrRewardPlacement = errors.New("joust participation reward placement failed")
|
||||
ErrSettlementRequired = errors.New("previous joust round requires settlement")
|
||||
ErrSettlementInvalid = errors.New("joust settlement is invalid")
|
||||
)
|
||||
|
||||
// RewardPlacer projects the current PVF participation item into the already
|
||||
// cloned inventory aggregate. The transport supplies the current-client raw
|
||||
// row construction, while Owner keeps the mutation inside one repository
|
||||
// transaction with the crystal debit and durable bet ledger.
|
||||
type RewardPlacer func(*dnfrepo.InventoryRecord) (int16, dnfrepo.ItemStack, error)
|
||||
|
||||
type Command struct {
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
Round uint16
|
||||
Knight byte
|
||||
SourceSlot int16
|
||||
Amount uint32
|
||||
UpdatedAt time.Time
|
||||
KnightAllowed func(byte) bool
|
||||
PlaceReward RewardPlacer
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
CharacterID string
|
||||
Round uint16
|
||||
Knight byte
|
||||
Amount uint32
|
||||
RoundTotal uint32
|
||||
SourceSlot int16
|
||||
SourceItemID int64
|
||||
SourceRemoved bool
|
||||
RemainingSource dnfrepo.ItemStack
|
||||
RewardSlot int16
|
||||
ParticipationItem dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
// KnightAmountStat returns the durable stat key for one rider's support in
|
||||
// the current joust round. Rider identifiers are current-PVF bytes, so using
|
||||
// their decimal representation keeps the state inspectable in the local DB.
|
||||
func KnightAmountStat(knight byte) string {
|
||||
return KnightAmountStatPrefix + strconv.FormatUint(uint64(knight), 10)
|
||||
}
|
||||
|
||||
// CurrentRoundBets reconstructs every rider wager for one round. It accepts
|
||||
// the original one-rider ledger as a fallback, preserving wagers placed before
|
||||
// split support was enabled.
|
||||
func CurrentRoundBets(stats map[string]int64, round uint16) (map[byte]uint32, uint32, bool) {
|
||||
if stats == nil || stats[RoundStat] != int64(round) {
|
||||
return nil, 0, false
|
||||
}
|
||||
totalValue := stats[AmountStat]
|
||||
if totalValue <= 0 || totalValue > MaximumBetPerRound {
|
||||
return nil, 0, false
|
||||
}
|
||||
wantTotal := uint32(totalValue)
|
||||
bets := make(map[byte]uint32, OpeningKnightCount)
|
||||
var actualTotal uint32
|
||||
for key, value := range stats {
|
||||
if !strings.HasPrefix(key, KnightAmountStatPrefix) || value == 0 {
|
||||
continue
|
||||
}
|
||||
if value < 0 || value > MaximumBetPerRound {
|
||||
return nil, 0, false
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(key, KnightAmountStatPrefix), 10, 8)
|
||||
if err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
amount := uint32(value)
|
||||
if amount > MaximumBetPerRound-actualTotal {
|
||||
return nil, 0, false
|
||||
}
|
||||
actualTotal += amount
|
||||
bets[byte(id)] += amount
|
||||
}
|
||||
if actualTotal == 0 {
|
||||
knight := stats[KnightStat]
|
||||
if knight < 0 || knight > math.MaxUint8 {
|
||||
return nil, 0, false
|
||||
}
|
||||
bets[byte(knight)] = wantTotal
|
||||
return bets, wantTotal, true
|
||||
}
|
||||
if actualTotal != wantTotal {
|
||||
return nil, 0, false
|
||||
}
|
||||
return bets, actualTotal, true
|
||||
}
|
||||
|
||||
// CurrentRoundSourceItem returns the exact material debited for a round. Old
|
||||
// one-rider ledgers predate this field and are permanently interpreted as the
|
||||
// original permanent crystal, preserving a claim that was already placed.
|
||||
func CurrentRoundSourceItem(stats map[string]int64, round uint16) (int64, bool) {
|
||||
if stats == nil || stats[RoundStat] != int64(round) {
|
||||
return 0, false
|
||||
}
|
||||
itemID := stats[SourceItemIDStat]
|
||||
if itemID == 0 {
|
||||
return PermanentCrystalID, true
|
||||
}
|
||||
return itemID, itemID == PermanentCrystalID || itemID == EventCrystalID
|
||||
}
|
||||
|
||||
func clearKnightAmountStats(stats map[string]int64) {
|
||||
for key := range stats {
|
||||
if strings.HasPrefix(key, KnightAmountStatPrefix) {
|
||||
delete(stats, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
assets dnfrepo.CharacterAssetUnitOfWork
|
||||
mailboxAssets dnfrepo.MailboxAssetUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterAssets == nil || repositories.MailboxAssets == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{assets: repositories.CharacterAssets, mailboxAssets: repositories.MailboxAssets}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Bet(ctx context.Context, cmd Command) (Result, error) {
|
||||
if o == nil || o.assets == nil {
|
||||
return Result{}, ErrOwnerUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 || cmd.KnightAllowed == nil ||
|
||||
cmd.SourceSlot < 0 || cmd.Amount == 0 || cmd.Amount > MaximumBetPerRound ||
|
||||
cmd.PlaceReward == nil {
|
||||
return Result{}, ErrRequestInvalid
|
||||
}
|
||||
if !cmd.KnightAllowed(cmd.Knight) {
|
||||
return Result{}, ErrKnightUnavailable
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
result := Result{
|
||||
CharacterID: characterID,
|
||||
Round: cmd.Round,
|
||||
Knight: cmd.Knight,
|
||||
Amount: cmd.Amount,
|
||||
SourceSlot: cmd.SourceSlot,
|
||||
}
|
||||
err := o.assets.WithinCharacterAssets(ctx, characterID, func(
|
||||
characters dnfrepo.CharacterRepository,
|
||||
inventories dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
) error {
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
|
||||
}
|
||||
if character.Level < MinimumLevel {
|
||||
return fmt.Errorf("%w: level=%d", ErrLevelTooLow, character.Level)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 5)
|
||||
}
|
||||
|
||||
previousAmount := uint32(0)
|
||||
previousSourceItemID := int64(0)
|
||||
previousBets := make(map[byte]uint32, OpeningKnightCount)
|
||||
if character.Stats[PendingStat] != 0 && character.Stats[RoundStat] != int64(cmd.Round) {
|
||||
return ErrSettlementRequired
|
||||
}
|
||||
if character.Stats[RoundStat] == int64(cmd.Round) && character.Stats[AmountStat] != 0 {
|
||||
var valid bool
|
||||
previousBets, previousAmount, valid = CurrentRoundBets(character.Stats, cmd.Round)
|
||||
if !valid {
|
||||
return ErrRequestInvalid
|
||||
}
|
||||
if previousSourceItemID, valid = CurrentRoundSourceItem(character.Stats, cmd.Round); !valid {
|
||||
return ErrRequestInvalid
|
||||
}
|
||||
} else {
|
||||
// A settled prior round must not leak its split support into this
|
||||
// new opening. It remains available until the next wager begins.
|
||||
clearKnightAmountStats(character.Stats)
|
||||
}
|
||||
if cmd.Amount > MaximumBetPerRound-previousAmount {
|
||||
return ErrRoundLimit
|
||||
}
|
||||
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
key := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, cmd.SourceSlot)
|
||||
source, found := inventory.Slots[key]
|
||||
if !found || source.ItemID <= 0 || source.Count <= 0 {
|
||||
return fmt.Errorf("%w: slot=%d", ErrCrystalMissing, cmd.SourceSlot)
|
||||
}
|
||||
if source.ItemID != PermanentCrystalID && source.ItemID != EventCrystalID {
|
||||
return fmt.Errorf("%w: slot=%d item=%d", ErrCrystalInvalid, cmd.SourceSlot, source.ItemID)
|
||||
}
|
||||
if previousAmount != 0 && previousSourceItemID != source.ItemID {
|
||||
return fmt.Errorf("%w: previous=%d requested=%d", ErrCrystalChanged, previousSourceItemID, source.ItemID)
|
||||
}
|
||||
if source.Count < int64(cmd.Amount) {
|
||||
return fmt.Errorf("%w: have=%d need=%d", ErrCrystalInsufficient, source.Count, cmd.Amount)
|
||||
}
|
||||
result.SourceItemID = source.ItemID
|
||||
source.Count -= int64(cmd.Amount)
|
||||
if source.Count == 0 {
|
||||
delete(inventory.Slots, key)
|
||||
result.SourceRemoved = true
|
||||
} else {
|
||||
inventory.Slots[key] = source
|
||||
result.RemainingSource = source
|
||||
}
|
||||
|
||||
rewardSlot, reward, err := cmd.PlaceReward(&inventory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrRewardPlacement, err)
|
||||
}
|
||||
if rewardSlot < 0 || reward.ItemID != ParticipationItemID || reward.Count <= 0 ||
|
||||
reward.Count > math.MaxUint32 {
|
||||
return ErrRewardPlacement
|
||||
}
|
||||
rewardKey := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, rewardSlot)
|
||||
storedReward, found := inventory.Slots[rewardKey]
|
||||
if !found || storedReward.ItemID != reward.ItemID || storedReward.Count != reward.Count {
|
||||
return ErrRewardPlacement
|
||||
}
|
||||
result.RewardSlot = rewardSlot
|
||||
result.ParticipationItem = reward
|
||||
result.RoundTotal = previousAmount + cmd.Amount
|
||||
previousBets[cmd.Knight] += cmd.Amount
|
||||
|
||||
now := cmd.UpdatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
character.Stats[RoundStat] = int64(cmd.Round)
|
||||
character.Stats[KnightStat] = int64(cmd.Knight)
|
||||
character.Stats[AmountStat] = int64(result.RoundTotal)
|
||||
character.Stats[SourceItemIDStat] = source.ItemID
|
||||
for knight, amount := range previousBets {
|
||||
character.Stats[KnightAmountStat(knight)] = int64(amount)
|
||||
}
|
||||
character.Stats[PendingStat] = 1
|
||||
character.Stats[LastBetUnixStat] = now.Unix()
|
||||
character.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
inventory.UpdatedAt = now
|
||||
return dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots)
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerBetAtomicallyConsumesCrystalGrantsParticipationAndPersistsLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Bet(ctx, Command{
|
||||
AccountID: "account-1",
|
||||
SelectedCharacterID: 19,
|
||||
Round: 7,
|
||||
Knight: 1,
|
||||
SourceSlot: 7,
|
||||
Amount: 3,
|
||||
KnightAllowed: allowTestKnight,
|
||||
PlaceReward: func(inventory *dnfrepo.InventoryRecord) (int16, dnfrepo.ItemStack, error) {
|
||||
stack := dnfrepo.ItemStack{ItemID: ParticipationItemID, Count: 3}
|
||||
inventory.Slots["0:8"] = stack
|
||||
return 8, stack, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RoundTotal != 3 || result.RemainingSource.Count != 7 || result.RewardSlot != 8 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
if character.Stats[RoundStat] != 7 || character.Stats[KnightStat] != 1 ||
|
||||
character.Stats[AmountStat] != 3 || character.Stats[SourceItemIDStat] != PermanentCrystalID ||
|
||||
character.Stats[PendingStat] != 1 {
|
||||
t.Fatalf("stats=%v", character.Stats)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "19")
|
||||
if inventory.Slots["0:7"].Count != 7 || inventory.Slots["0:8"].ItemID != ParticipationItemID || inventory.Slots["0:8"].Count != 3 {
|
||||
t.Fatalf("inventory=%v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerBetPlacementFailureRollsBackCrystalAndLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
owner, _ := NewOwner(repos)
|
||||
_, err := owner.Bet(ctx, Command{
|
||||
SelectedCharacterID: 19,
|
||||
Round: 7,
|
||||
Knight: 1,
|
||||
SourceSlot: 7,
|
||||
Amount: 3,
|
||||
KnightAllowed: allowTestKnight,
|
||||
PlaceReward: func(*dnfrepo.InventoryRecord) (int16, dnfrepo.ItemStack, error) {
|
||||
return 0, dnfrepo.ItemStack{}, errors.New("full")
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrRewardPlacement) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "19")
|
||||
if character.Stats[PendingStat] != 0 || inventory.Slots["0:7"].Count != 10 {
|
||||
t.Fatalf("rollback stats=%v inventory=%v", character.Stats, inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerBetAllowsSplitSupportAcrossKnightsAndEnforcesTotalRoundLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 20_000)
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
character.Stats = map[string]int64{RoundStat: 7, KnightStat: 1, AmountStat: 9_999, PendingStat: 1}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
placer := func(inventory *dnfrepo.InventoryRecord) (int16, dnfrepo.ItemStack, error) {
|
||||
stack := dnfrepo.ItemStack{ItemID: ParticipationItemID, Count: 1}
|
||||
inventory.Slots["0:8"] = stack
|
||||
return 8, stack, nil
|
||||
}
|
||||
result, err := owner.Bet(ctx, Command{SelectedCharacterID: 19, Round: 7, Knight: 2, SourceSlot: 7, Amount: 1, KnightAllowed: allowTestKnight, PlaceReward: placer})
|
||||
if err != nil || result.RoundTotal != 10_000 {
|
||||
t.Fatalf("split wager result=%+v err=%v", result, err)
|
||||
}
|
||||
if _, err := owner.Bet(ctx, Command{SelectedCharacterID: 19, Round: 7, Knight: 1, SourceSlot: 7, Amount: 1, KnightAllowed: allowTestKnight, PlaceReward: placer}); !errors.Is(err, ErrRoundLimit) {
|
||||
t.Fatalf("limit error=%v", err)
|
||||
}
|
||||
character, _, _ = repos.Character.Load(ctx, "19")
|
||||
if character.Stats[KnightAmountStat(1)] != 9_999 || character.Stats[KnightAmountStat(2)] != 1 || character.Stats[AmountStat] != 10_000 {
|
||||
t.Fatalf("split stats=%v", character.Stats)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "19")
|
||||
if inventory.Slots["0:7"].Count != 19_999 {
|
||||
t.Fatalf("source count=%d", inventory.Slots["0:7"].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func allowTestKnight(knight byte) bool {
|
||||
return knight < OpeningKnightCount
|
||||
}
|
||||
|
||||
func betRepositories(t *testing.T, count int64) dnfrepo.Group {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "19",
|
||||
AccountID: "account-1",
|
||||
Level: 90,
|
||||
Stats: map[string]int64{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:7": {ItemID: PermanentCrystalID, Count: count},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repos
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const RoundDuration = 2 * time.Hour
|
||||
|
||||
const (
|
||||
BettingDuration = 90 * time.Minute
|
||||
QuarterFinalEnd = 95 * time.Minute
|
||||
SemiFinalEnd = 100 * time.Minute
|
||||
FinalEnd = 105 * time.Minute
|
||||
SettlementStart = 110 * time.Minute
|
||||
|
||||
// MysteryRiderStatus is the current client's explicit hidden-rider flag in
|
||||
// class-0/op1241. The eighth entrant keeps its real PVF ID for wagering
|
||||
// and settlement, but this marker makes the initial bracket render the
|
||||
// native black "mystery knight" instead of revealing that ID up front.
|
||||
MysteryRiderStatus byte = 1
|
||||
)
|
||||
|
||||
var ErrRoundUnavailable = errors.New("joust round is unavailable")
|
||||
|
||||
type BettingLedger struct {
|
||||
Round uint16
|
||||
Knight byte
|
||||
Amount uint32
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type OpeningRider struct {
|
||||
ID byte
|
||||
AttackType byte
|
||||
Multiplier float32
|
||||
Wins uint16
|
||||
Losses uint16
|
||||
Status byte
|
||||
Support uint32
|
||||
}
|
||||
|
||||
type OpeningRound struct {
|
||||
Number uint16
|
||||
TotalSupport uint32
|
||||
Riders []OpeningRider
|
||||
}
|
||||
|
||||
// RoundNumberAt gives the all-day local profile a stable two-hour round key.
|
||||
// Unix time is used so restart and daylight-saving changes cannot create a
|
||||
// second identity for the same active round.
|
||||
func RoundNumberAt(now time.Time) uint16 {
|
||||
if now.IsZero() {
|
||||
return 0
|
||||
}
|
||||
seconds := now.Unix()
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
return uint16(uint64(seconds / int64(RoundDuration/time.Second)))
|
||||
}
|
||||
|
||||
// Opening builds the eight-rider class-0/op1241 view. The original event odds
|
||||
// are total support divided by support for that rider. A local single-account
|
||||
// server has no cross-channel population to seed that pool, so each round uses
|
||||
// a deterministic background baseline. It is stable across restarts, changes
|
||||
// in the next round, and the durable local bet is overlaid on its rider.
|
||||
func (c *Catalog) Opening(round uint16, ledger BettingLedger) (OpeningRound, error) {
|
||||
ledgers := []BettingLedger(nil)
|
||||
if ledger.Valid {
|
||||
ledgers = []BettingLedger{ledger}
|
||||
}
|
||||
return c.OpeningWithLedgers(round, ledgers)
|
||||
}
|
||||
|
||||
// OpeningWithLedgers overlays every durable character ledger for the local
|
||||
// account. Pending is intentionally not part of BettingLedger: settlement
|
||||
// clears only the claim flag, while the round's public support pool and final
|
||||
// multiplier must remain stable until the next two-hour opening.
|
||||
func (c *Catalog) OpeningWithLedgers(round uint16, ledgers []BettingLedger) (OpeningRound, error) {
|
||||
if c == nil || len(c.riders) < OpeningKnightCount {
|
||||
return OpeningRound{}, ErrRoundUnavailable
|
||||
}
|
||||
selected, err := selectOpeningRiders(c.riders, round)
|
||||
if err != nil {
|
||||
return OpeningRound{}, err
|
||||
}
|
||||
opening := OpeningRound{
|
||||
Number: round,
|
||||
Riders: make([]OpeningRider, len(selected)),
|
||||
}
|
||||
for position, rider := range selected {
|
||||
support := openingBaselineSupport(round, position)
|
||||
for _, ledger := range ledgers {
|
||||
if ledger.Valid && ledger.Round == round && ledger.Knight == rider.ID {
|
||||
support += ledger.Amount
|
||||
}
|
||||
}
|
||||
opening.Riders[position] = OpeningRider{
|
||||
ID: rider.ID,
|
||||
AttackType: rider.AttackType,
|
||||
Support: support,
|
||||
}
|
||||
if position == OpeningKnightCount-1 {
|
||||
opening.Riders[position].Status = MysteryRiderStatus
|
||||
}
|
||||
opening.TotalSupport += support
|
||||
}
|
||||
if opening.TotalSupport == 0 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d total support is zero", ErrRoundUnavailable, round)
|
||||
}
|
||||
for index := range opening.Riders {
|
||||
support := opening.Riders[index].Support
|
||||
if support == 0 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d rider=%d support is zero", ErrRoundUnavailable, round, opening.Riders[index].ID)
|
||||
}
|
||||
multiplier := float32(opening.TotalSupport) / float32(support)
|
||||
if math.IsNaN(float64(multiplier)) || math.IsInf(float64(multiplier), 0) || multiplier <= 0 || multiplier > 999 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d rider=%d multiplier=%v", ErrRoundUnavailable, round, opening.Riders[index].ID, multiplier)
|
||||
}
|
||||
opening.Riders[index].Multiplier = multiplier
|
||||
}
|
||||
return opening, nil
|
||||
}
|
||||
|
||||
type Phase byte
|
||||
|
||||
const (
|
||||
PhaseBetting Phase = iota
|
||||
PhaseQuarterFinal
|
||||
PhaseSemiFinal
|
||||
PhaseFinal
|
||||
PhaseSettled
|
||||
)
|
||||
|
||||
type Timeline struct {
|
||||
Round uint16
|
||||
Elapsed time.Duration
|
||||
Phase Phase
|
||||
State byte
|
||||
Stage byte
|
||||
}
|
||||
|
||||
// TimelineAt maps the all-day two-hour activity into the original four state
|
||||
// notices and four progressive bracket snapshots. The 90/95/100/105/110
|
||||
// minute boundaries preserve the published 2017 betting and settlement
|
||||
// cadence while keeping the event available around the clock.
|
||||
func TimelineAt(now time.Time) Timeline {
|
||||
round := RoundNumberAt(now)
|
||||
elapsed := time.Duration(now.Unix()%int64(RoundDuration/time.Second)) * time.Second
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
timeline := Timeline{Round: round, Elapsed: elapsed, State: 1}
|
||||
switch {
|
||||
case elapsed < BettingDuration:
|
||||
timeline.Phase = PhaseBetting
|
||||
case elapsed < QuarterFinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseQuarterFinal, 2, 0
|
||||
case elapsed < SemiFinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseSemiFinal, 2, 1
|
||||
case elapsed < FinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseFinal, 3, 2
|
||||
case elapsed < SettlementStart:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseFinal, 3, 3
|
||||
default:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseSettled, 4, 3
|
||||
}
|
||||
return timeline
|
||||
}
|
||||
|
||||
func NextBoundaryAfter(now time.Time) time.Time {
|
||||
timeline := TimelineAt(now)
|
||||
start := now.Add(-timeline.Elapsed)
|
||||
for _, offset := range []time.Duration{BettingDuration, QuarterFinalEnd, SemiFinalEnd, FinalEnd, SettlementStart, RoundDuration} {
|
||||
candidate := start.Add(offset)
|
||||
if candidate.After(now) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return start.Add(RoundDuration)
|
||||
}
|
||||
|
||||
func (r OpeningRound) Contains(knight byte) bool {
|
||||
for _, rider := range r.Riders {
|
||||
if rider.ID == knight {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// openingRegularKnightIDs are the seven visible 2017 CN joust entrants:
|
||||
// 理查德、爱德华、罗兰、贝奥武夫、莱奥、伊萨尔、席恩. The eighth slot is intentionally
|
||||
// filled from mysteryKnightIDs for that round. The active PVF names ID 6 as
|
||||
// 吉利特 and ID 9 as 无双飞将吕布; both are valid mystery entries. Lancelot (ID 8)
|
||||
// is the activity-dungeon lord, never a joust entrant.
|
||||
var openingRegularKnightIDs = [...]byte{1, 0, 2, 3, 4, 5, 7}
|
||||
|
||||
var mysteryKnightIDs = [...]byte{6, 9, 10, 11}
|
||||
|
||||
func selectOpeningRiders(riders []Rider, round uint16) ([]Rider, error) {
|
||||
byID := make(map[byte]Rider, len(riders))
|
||||
for _, rider := range riders {
|
||||
if _, duplicate := byID[rider.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%w: duplicate roster rider=%d", ErrRoundUnavailable, rider.ID)
|
||||
}
|
||||
byID[rider.ID] = rider
|
||||
}
|
||||
selected := make([]Rider, 0, OpeningKnightCount)
|
||||
for _, id := range openingRegularKnightIDs {
|
||||
rider, found := byID[id]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: required original rider=%d missing", ErrRoundUnavailable, id)
|
||||
}
|
||||
selected = append(selected, cloneRider(rider))
|
||||
}
|
||||
// Keep the draw restart-stable, but choose only from the original mystery
|
||||
// pool rather than shuffling in unrelated normal/event NPCs.
|
||||
mysteryID := mysteryKnightIDs[(uint32(round)*1103515245+12345)%uint32(len(mysteryKnightIDs))]
|
||||
mystery, found := byID[mysteryID]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: required mystery rider=%d missing", ErrRoundUnavailable, mysteryID)
|
||||
}
|
||||
selected = append(selected, cloneRider(mystery))
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func openingBaselineSupport(round uint16, position int) uint32 {
|
||||
// Five is coprime to eight, so all eight ranks are distinct in every round.
|
||||
rank := (position*5 + int(round)%OpeningKnightCount) % OpeningKnightCount
|
||||
// The small round jitter changes the numeric odds while preserving distinct
|
||||
// support buckets and realistic single-digit/low-double-digit multipliers.
|
||||
jitter := uint32((uint32(round)*37 + uint32(position)*17) % 101)
|
||||
return 900 + uint32(rank)*325 + jitter
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRoundNumberAtUsesStableTwoHourWindows(t *testing.T) {
|
||||
start := time.Unix(7200*1234, 0)
|
||||
if got := RoundNumberAt(start); got != 1234 {
|
||||
t.Fatalf("round=%d want=1234", got)
|
||||
}
|
||||
if got := RoundNumberAt(start.Add(RoundDuration - time.Second)); got != 1234 {
|
||||
t.Fatalf("same-window round=%d want=1234", got)
|
||||
}
|
||||
if got := RoundNumberAt(start.Add(RoundDuration)); got != 1235 {
|
||||
t.Fatalf("next-window round=%d want=1235", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningHasEightDistinctNonZeroMultipliersAndChangesNextRound(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := catalog.Opening(77, BettingLedger{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Riders) != OpeningKnightCount || first.TotalSupport == 0 {
|
||||
t.Fatalf("opening=%+v", first)
|
||||
}
|
||||
ids := make(map[byte]struct{}, OpeningKnightCount)
|
||||
multipliers := make(map[uint32]struct{}, OpeningKnightCount)
|
||||
for _, rider := range first.Riders {
|
||||
if rider.Multiplier <= 0 || rider.Support == 0 {
|
||||
t.Fatalf("rider=%+v", rider)
|
||||
}
|
||||
ids[rider.ID] = struct{}{}
|
||||
multipliers[math.Float32bits(rider.Multiplier)] = struct{}{}
|
||||
}
|
||||
if len(ids) != OpeningKnightCount || len(multipliers) != OpeningKnightCount {
|
||||
t.Fatalf("distinct ids=%d multipliers=%d opening=%+v", len(ids), len(multipliers), first)
|
||||
}
|
||||
second, err := catalog.Opening(78, BettingLedger{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("next round did not change: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningOverlaysDurableLocalBetAndRecalculatesAllOdds(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, err := catalog.Opening(91, BettingLedger{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := base.Riders[3]
|
||||
updated, err := catalog.Opening(91, BettingLedger{Round: 91, Knight: target.ID, Amount: 500, Valid: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.TotalSupport != base.TotalSupport+500 {
|
||||
t.Fatalf("total=%d want=%d", updated.TotalSupport, base.TotalSupport+500)
|
||||
}
|
||||
for index := range base.Riders {
|
||||
wantSupport := base.Riders[index].Support
|
||||
if base.Riders[index].ID == target.ID {
|
||||
wantSupport += 500
|
||||
}
|
||||
if updated.Riders[index].Support != wantSupport || updated.Riders[index].Multiplier == base.Riders[index].Multiplier {
|
||||
t.Fatalf("rider[%d] base=%+v updated=%+v", index, base.Riders[index], updated.Riders[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningUsesSevenRegularKnightsAndOnlyAnEligibleMysteryKnight(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular := []byte{1, 0, 2, 3, 4, 5, 7}
|
||||
mysteries := map[byte]struct{}{6: {}, 9: {}, 10: {}, 11: {}}
|
||||
seenMysteries := make(map[byte]struct{}, len(mysteries))
|
||||
for round := uint16(0); round < 16; round++ {
|
||||
opening, openingErr := catalog.Opening(round, BettingLedger{})
|
||||
if openingErr != nil {
|
||||
t.Fatalf("round=%d: %v", round, openingErr)
|
||||
}
|
||||
for index, id := range regular {
|
||||
if opening.Riders[index].ID != id || opening.Riders[index].Status != 0 {
|
||||
t.Fatalf("round=%d regular[%d]=%+v want_id=%d visible", round, index, opening.Riders[index], id)
|
||||
}
|
||||
}
|
||||
mysteryRider := opening.Riders[OpeningKnightCount-1]
|
||||
mystery := mysteryRider.ID
|
||||
if _, allowed := mysteries[mystery]; !allowed {
|
||||
t.Fatalf("round=%d mystery=%d is not an original mystery rider", round, mystery)
|
||||
}
|
||||
if mysteryRider.Status != MysteryRiderStatus {
|
||||
t.Fatalf("round=%d mystery=%+v missing hidden status=%d", round, mysteryRider, MysteryRiderStatus)
|
||||
}
|
||||
seenMysteries[mystery] = struct{}{}
|
||||
}
|
||||
if len(seenMysteries) != len(mysteries) {
|
||||
t.Fatalf("seen mystery riders=%v want=%v", seenMysteries, mysteries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// SettlementMailDelivery creates the durable reward mail inside the same
|
||||
// mailbox-assets transaction that clears the betting claim.
|
||||
type SettlementMailDelivery func(dnfrepo.MailboxRepository, int64, uint32) (string, error)
|
||||
|
||||
type SettlementCommand struct {
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
Round uint16
|
||||
Winner byte
|
||||
Multiplier float32
|
||||
SettledAt time.Time
|
||||
DeliverReward SettlementMailDelivery
|
||||
}
|
||||
|
||||
type SettlementResult struct {
|
||||
CharacterID string
|
||||
Round uint16
|
||||
Knight byte
|
||||
Amount uint32
|
||||
Winner byte
|
||||
Payout uint32
|
||||
RewardItemID int64
|
||||
MailID string
|
||||
Won bool
|
||||
Settled bool
|
||||
}
|
||||
|
||||
// Settle clears one pending claim and writes the winning material payout as a
|
||||
// system-mail attachment in the same mailbox-assets transaction. A failed
|
||||
// delivery rolls back the pending flag, so reconnect/restart retries cannot
|
||||
// lose or duplicate the result.
|
||||
func (o *Owner) Settle(ctx context.Context, cmd SettlementCommand) (SettlementResult, error) {
|
||||
if o == nil || o.mailboxAssets == nil {
|
||||
return SettlementResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 || cmd.Multiplier <= 0 ||
|
||||
math.IsNaN(float64(cmd.Multiplier)) || math.IsInf(float64(cmd.Multiplier), 0) ||
|
||||
cmd.DeliverReward == nil {
|
||||
return SettlementResult{}, ErrSettlementInvalid
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
result := SettlementResult{CharacterID: characterID, Round: cmd.Round, Winner: cmd.Winner}
|
||||
err := o.mailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||||
_ dnfrepo.AccountRepository,
|
||||
_ dnfrepo.AccountInventoryRepository,
|
||||
characters dnfrepo.CharacterRepository,
|
||||
_ dnfrepo.InventoryRepository,
|
||||
mailboxes dnfrepo.MailboxRepository,
|
||||
) error {
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil || character.Stats[PendingStat] == 0 {
|
||||
return nil
|
||||
}
|
||||
round := character.Stats[RoundStat]
|
||||
bets, totalAmount, valid := CurrentRoundBets(character.Stats, cmd.Round)
|
||||
if round != int64(cmd.Round) || !valid {
|
||||
return ErrSettlementInvalid
|
||||
}
|
||||
rewardItemID, valid := CurrentRoundSourceItem(character.Stats, cmd.Round)
|
||||
if !valid {
|
||||
return ErrSettlementInvalid
|
||||
}
|
||||
winningAmount := bets[cmd.Winner]
|
||||
result.Knight = cmd.Winner
|
||||
result.Amount = totalAmount
|
||||
result.Won = winningAmount > 0
|
||||
if result.Won {
|
||||
payout := math.Floor(float64(winningAmount) * float64(cmd.Multiplier))
|
||||
if payout < 1 || payout > math.MaxUint32 {
|
||||
return ErrSettlementInvalid
|
||||
}
|
||||
result.Payout = uint32(payout)
|
||||
mailID, err := cmd.DeliverReward(mailboxes, rewardItemID, result.Payout)
|
||||
if err != nil || strings.TrimSpace(mailID) == "" {
|
||||
if err == nil {
|
||||
err = ErrRewardPlacement
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrRewardPlacement, err)
|
||||
}
|
||||
result.RewardItemID = rewardItemID
|
||||
result.MailID = mailID
|
||||
settledAt := cmd.SettledAt
|
||||
if settledAt.IsZero() {
|
||||
settledAt = time.Now().UTC()
|
||||
}
|
||||
}
|
||||
settledAt := cmd.SettledAt
|
||||
if settledAt.IsZero() {
|
||||
settledAt = time.Now().UTC()
|
||||
}
|
||||
character.Stats[PendingStat] = 0
|
||||
character.Stats[WinnerStat] = int64(cmd.Winner)
|
||||
character.Stats[PayoutStat] = int64(result.Payout)
|
||||
character.Stats[SettledUnixStat] = settledAt.UTC().Unix()
|
||||
character.UpdatedAt = settledAt.UTC()
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Settled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return SettlementResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestOwnerSettlementPaysWinnerAndIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
character.Stats = map[string]int64{RoundStat: 7, KnightStat: 1, AmountStat: 3, PendingStat: 1}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
deliver := func(mailbox dnfrepo.MailboxRepository, itemID int64, count uint32) (string, error) {
|
||||
return dnfrepo.AppendSystemMail(ctx, mailbox, dnfrepo.SystemMailDelivery{
|
||||
RecipientCharacterID: "19",
|
||||
Title: "test",
|
||||
Source: "joust-test",
|
||||
Attachments: []dnfrepo.MailAttachment{{ItemID: itemID, Count: int64(count)}},
|
||||
})
|
||||
}
|
||||
result, err := owner.Settle(ctx, SettlementCommand{SelectedCharacterID: 19, Round: 7, Winner: 1, Multiplier: 2.5, DeliverReward: deliver})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Settled || !result.Won || result.Payout != 7 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
second, err := owner.Settle(ctx, SettlementCommand{SelectedCharacterID: 19, Round: 7, Winner: 1, Multiplier: 2.5, DeliverReward: deliver})
|
||||
if err != nil || second.Settled {
|
||||
t.Fatalf("second=%+v err=%v", second, err)
|
||||
}
|
||||
mailbox, _, _ := repos.Mailbox.Load(ctx, "19")
|
||||
mail := mailbox.Mails[result.MailID]
|
||||
if result.MailID == "" || result.RewardItemID != PermanentCrystalID || len(mail.Attachments) != 1 ||
|
||||
mail.Attachments[0].ItemID != PermanentCrystalID || mail.Attachments[0].Count != 7 {
|
||||
t.Fatalf("missing or duplicate payout mail result=%+v mailbox=%+v", result, mailbox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerSettlementPlacementFailureKeepsPendingClaim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
character.Stats = map[string]int64{RoundStat: 7, KnightStat: 1, AmountStat: 3, PendingStat: 1}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
_, err := owner.Settle(ctx, SettlementCommand{
|
||||
SelectedCharacterID: 19,
|
||||
Round: 7,
|
||||
Winner: 1,
|
||||
Multiplier: 2.5,
|
||||
DeliverReward: func(dnfrepo.MailboxRepository, int64, uint32) (string, error) {
|
||||
return "", errors.New("mail unavailable")
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrRewardPlacement) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
character, _, _ = repos.Character.Load(ctx, "19")
|
||||
if character.Stats[PendingStat] != 1 {
|
||||
t.Fatalf("claim was lost stats=%v", character.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerSettlementPaysOnlyTheWinningPortionOfSplitSupport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
character.Stats = map[string]int64{
|
||||
RoundStat: 7,
|
||||
KnightStat: 2,
|
||||
AmountStat: 10,
|
||||
KnightAmountStat(1): 3,
|
||||
KnightAmountStat(2): 7,
|
||||
PendingStat: 1,
|
||||
}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
result, err := owner.Settle(ctx, SettlementCommand{
|
||||
SelectedCharacterID: 19,
|
||||
Round: 7,
|
||||
Winner: 1,
|
||||
Multiplier: 2.5,
|
||||
DeliverReward: func(mailbox dnfrepo.MailboxRepository, itemID int64, count uint32) (string, error) {
|
||||
return dnfrepo.AppendSystemMail(ctx, mailbox, dnfrepo.SystemMailDelivery{
|
||||
RecipientCharacterID: "19",
|
||||
Title: "test",
|
||||
Source: "joust-test",
|
||||
Attachments: []dnfrepo.MailAttachment{{ItemID: itemID, Count: int64(count)}},
|
||||
})
|
||||
},
|
||||
})
|
||||
if err != nil || !result.Settled || !result.Won || result.Amount != 10 || result.Payout != 7 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerSettlementReturnsTheActualStakedMaterialByMail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := betRepositories(t, 10)
|
||||
character, _, _ := repos.Character.Load(ctx, "19")
|
||||
character.Stats = map[string]int64{
|
||||
RoundStat: 7,
|
||||
KnightStat: 1,
|
||||
AmountStat: 3,
|
||||
SourceItemIDStat: EventCrystalID,
|
||||
PendingStat: 1,
|
||||
}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
result, err := owner.Settle(ctx, SettlementCommand{
|
||||
SelectedCharacterID: 19,
|
||||
Round: 7,
|
||||
Winner: 1,
|
||||
Multiplier: 2.5,
|
||||
DeliverReward: func(mailbox dnfrepo.MailboxRepository, itemID int64, count uint32) (string, error) {
|
||||
return dnfrepo.AppendSystemMail(ctx, mailbox, dnfrepo.SystemMailDelivery{
|
||||
RecipientCharacterID: "19",
|
||||
Title: "test",
|
||||
Source: "joust-test",
|
||||
Attachments: []dnfrepo.MailAttachment{{ItemID: itemID, Count: int64(count)}},
|
||||
})
|
||||
},
|
||||
})
|
||||
if err != nil || !result.Settled || result.RewardItemID != EventCrystalID || result.Payout != 7 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
mailbox, _, _ := repos.Mailbox.Load(ctx, "19")
|
||||
mail := mailbox.Mails[result.MailID]
|
||||
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != EventCrystalID || mail.Attachments[0].Count != 7 {
|
||||
t.Fatalf("mail=%+v", mail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type Match struct {
|
||||
Winner byte
|
||||
WinnerAction byte
|
||||
Loser byte
|
||||
LoserAction byte
|
||||
}
|
||||
|
||||
type Tournament struct {
|
||||
Round uint16
|
||||
Matches [7]Match
|
||||
}
|
||||
|
||||
func (t Tournament) Champion() byte {
|
||||
return t.Matches[6].Winner
|
||||
}
|
||||
|
||||
// TournamentFor deterministically completes the four quarter-finals, two
|
||||
// semi-finals and final. The PVF supplies four client battle timeline profiles
|
||||
// per rider but no rider strength or win-probability field, so winner selection
|
||||
// is an unbiased deterministic coin. This makes restarts reproduce the same
|
||||
// bracket without inventing a hidden ranking.
|
||||
func (c *Catalog) TournamentFor(round uint16) (Tournament, error) {
|
||||
if c == nil || len(c.riders) < OpeningKnightCount {
|
||||
return Tournament{}, ErrRoundUnavailable
|
||||
}
|
||||
roster, err := selectOpeningRiders(c.riders, round)
|
||||
if err != nil {
|
||||
return Tournament{}, err
|
||||
}
|
||||
tournament := Tournament{Round: round}
|
||||
participants := make([]byte, OpeningKnightCount)
|
||||
for index := range roster {
|
||||
participants[index] = roster[index].ID
|
||||
}
|
||||
matchIndex := 0
|
||||
next := make([]byte, 0, 4)
|
||||
for index := 0; index < len(participants); index += 2 {
|
||||
match, err := c.simulateMatch(round, matchIndex, participants[index], participants[index+1])
|
||||
if err != nil {
|
||||
return Tournament{}, err
|
||||
}
|
||||
tournament.Matches[matchIndex] = match
|
||||
next = append(next, match.Winner)
|
||||
matchIndex++
|
||||
}
|
||||
semi := make([]byte, 0, 2)
|
||||
for index := 0; index < len(next); index += 2 {
|
||||
match, err := c.simulateMatch(round, matchIndex, next[index], next[index+1])
|
||||
if err != nil {
|
||||
return Tournament{}, err
|
||||
}
|
||||
tournament.Matches[matchIndex] = match
|
||||
semi = append(semi, match.Winner)
|
||||
matchIndex++
|
||||
}
|
||||
final, err := c.simulateMatch(round, matchIndex, semi[0], semi[1])
|
||||
if err != nil {
|
||||
return Tournament{}, err
|
||||
}
|
||||
tournament.Matches[matchIndex] = final
|
||||
return tournament, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) simulateMatch(round uint16, index int, leftID, rightID byte) (Match, error) {
|
||||
left, leftOK := c.rider(leftID)
|
||||
right, rightOK := c.rider(rightID)
|
||||
if !leftOK || !rightOK || left.ID == right.ID {
|
||||
return Match{}, fmt.Errorf("%w: match=%d riders=%d/%d", ErrRoundUnavailable, index, leftID, rightID)
|
||||
}
|
||||
state := tournamentRandomState(round, index, leftID, rightID)
|
||||
winner, loser := left, right
|
||||
if nextTournamentRandom(&state)&1 != 0 {
|
||||
winner, loser = right, left
|
||||
}
|
||||
winnerAction, err := battleAction(winner.Win, loser.AttackType)
|
||||
if err != nil {
|
||||
return Match{}, err
|
||||
}
|
||||
loserAction, err := battleAction(loser.Loss, winner.AttackType)
|
||||
if err != nil {
|
||||
return Match{}, err
|
||||
}
|
||||
return Match{Winner: winner.ID, WinnerAction: winnerAction, Loser: loser.ID, LoserAction: loserAction}, nil
|
||||
}
|
||||
|
||||
// battleAction returns the profile key consumed by the current EXE's
|
||||
// sub_365C180 nested map lookup. Each rider's [win]/[loss] section is four
|
||||
// seven-value timelines selected by the opponent's attack type. The values are
|
||||
// timeline percentages, not seven independently selectable action IDs.
|
||||
// Returning a value from a CDF roll here used to emit keys 5 and 6; the client
|
||||
// inserted an empty vector for those absent keys and then deliberately aborted
|
||||
// in sub_F2EE20 when it compared the two match timelines.
|
||||
func battleAction(values []uint16, opponentAttackType byte) (byte, error) {
|
||||
offset, ok := attackTypeTableOffset(opponentAttackType)
|
||||
if !ok || len(values) != 28 {
|
||||
return 0, ErrPVFMalformed
|
||||
}
|
||||
if values[offset+6] == 0 {
|
||||
return 0, ErrPVFMalformed
|
||||
}
|
||||
return byte(offset / 7), nil
|
||||
}
|
||||
|
||||
func tournamentRandomState(round uint16, index int, left, right byte) uint32 {
|
||||
state := uint32(round)*0x9E3779B9 ^ uint32(index+1)*0x85EBCA6B ^ uint32(left)<<8 ^ uint32(right)
|
||||
if state == 0 {
|
||||
state = math.MaxUint32
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func nextTournamentRandom(state *uint32) uint32 {
|
||||
*state ^= *state << 13
|
||||
*state ^= *state >> 17
|
||||
*state ^= *state << 5
|
||||
return *state
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTournamentForBuildsStableCompleteBracket(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := catalog.TournamentFor(91)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := catalog.TournamentFor(91)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) || first.Champion() != first.Matches[6].Winner {
|
||||
t.Fatalf("unstable tournament first=%+v second=%+v", first, second)
|
||||
}
|
||||
for index, match := range first.Matches {
|
||||
if match.Winner == match.Loser || match.WinnerAction > 3 || match.LoserAction > 3 {
|
||||
t.Fatalf("match[%d]=%+v", index, match)
|
||||
}
|
||||
}
|
||||
if first.Matches[4].Winner != first.Matches[6].Winner && first.Matches[5].Winner != first.Matches[6].Winner {
|
||||
t.Fatalf("final champion not a semifinal winner: %+v", first.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleActionUsesOpponentAttackTypeProfileKey(t *testing.T) {
|
||||
values := []uint16{
|
||||
0, 20, 35, 65, 75, 95, 100,
|
||||
0, 15, 20, 50, 80, 100, 100,
|
||||
0, 35, 60, 80, 80, 90, 100,
|
||||
0, 20, 35, 55, 75, 90, 100,
|
||||
}
|
||||
for _, test := range []struct {
|
||||
attackType byte
|
||||
want byte
|
||||
}{{0, 0}, {1, 1}, {27, 2}, {28, 3}} {
|
||||
got, err := battleAction(values, test.attackType)
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("attack_type=%d action=%d err=%v want=%d", test.attackType, got, err, test.want)
|
||||
}
|
||||
}
|
||||
if _, err := battleAction(values, 2); err == nil {
|
||||
t.Fatal("unsupported attack type did not fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTournamentActionsMatchEachOpponentAttackType(t *testing.T) {
|
||||
catalog, err := LoadCatalog(context.Background(), joustCatalogTestSource{EventPVFPath: testJoustPVF(12)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for round := uint16(0); ; round++ {
|
||||
tournament, tournamentErr := catalog.TournamentFor(round)
|
||||
if tournamentErr != nil {
|
||||
t.Fatalf("round=%d: %v", round, tournamentErr)
|
||||
}
|
||||
for index, match := range tournament.Matches {
|
||||
winner, winnerOK := catalog.rider(match.Winner)
|
||||
loser, loserOK := catalog.rider(match.Loser)
|
||||
winnerOffset, winnerTypeOK := attackTypeTableOffset(loser.AttackType)
|
||||
loserOffset, loserTypeOK := attackTypeTableOffset(winner.AttackType)
|
||||
if !winnerOK || !loserOK || !winnerTypeOK || !loserTypeOK ||
|
||||
match.WinnerAction != byte(winnerOffset/7) || match.LoserAction != byte(loserOffset/7) {
|
||||
t.Fatalf("round=%d match[%d]=%+v winner=%+v loser=%+v", round, index, match, winner, loser)
|
||||
}
|
||||
}
|
||||
if round == ^uint16(0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimelineAtUsesBettingBracketAndSettlementBoundaries(t *testing.T) {
|
||||
start := time.Unix(7200*900, 0).UTC()
|
||||
tests := []struct {
|
||||
offset time.Duration
|
||||
phase Phase
|
||||
state byte
|
||||
stage byte
|
||||
}{
|
||||
{0, PhaseBetting, 1, 0},
|
||||
{90 * time.Minute, PhaseQuarterFinal, 2, 0},
|
||||
{95 * time.Minute, PhaseSemiFinal, 2, 1},
|
||||
{100 * time.Minute, PhaseFinal, 3, 2},
|
||||
{105 * time.Minute, PhaseFinal, 3, 3},
|
||||
{110 * time.Minute, PhaseSettled, 4, 3},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got := TimelineAt(start.Add(test.offset))
|
||||
if got.Round != 900 || got.Phase != test.phase || got.State != test.state || got.Stage != test.stage {
|
||||
t.Fatalf("offset=%s timeline=%+v", test.offset, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user