DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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()
}
+324
View File
@@ -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
}
+238
View File
@@ -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)
}
}
}