This commit is contained in:
yml2213
2026-10-25 15:02:41 +08:00
commit ccd51b91fb
32 changed files with 5811 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
package pricing
import (
"math"
"strings"
)
// Calculate 只计算号主到手价:参考比例、加速比例、纯币价、物品价和合计。
func (config Config) Calculate(input Input) Result {
cfg := Normalize(config)
coinM := math.Max(input.CoinM, 0)
coinWan := coinM * 100
result := Result{
Items: []ItemLine{},
Valid: false,
}
baseRatio, ok := insuranceBaseRatio(cfg.InsuranceBaseRatios, input.Insurance)
if !ok {
result.Message = "未找到该保险格数的基础比例,请检查规则配置"
result.ItemPrice, result.Items = calculateItemLines(cfg, input)
result.TotalPrice = result.ItemPrice
return result
}
result.InsuranceBaseRatio = baseRatio
result.ConfigPenalty = calculateConfigPenalty(cfg.RatioConfigItems, input.States)
result.CoinCorrection = coinCorrection(cfg.CoinCorrections, coinM)
result.DailyLossAdjustment = dailyLossRatioAdjustment(input.DailyLossM)
referenceRatio := RoundRatio(
baseRatio +
result.ConfigPenalty +
result.CoinCorrection +
result.DailyLossAdjustment,
)
result.ReferenceRatio = referenceRatio
result.MaxRatio = RoundRatio(referenceRatio + cfg.AcceleratedMaxRatio)
result.FinalRatio = referenceRatio
if input.SaleRatio > 0 {
result.FinalRatio = RoundRatio(clampSaleRatio(input.SaleRatio, referenceRatio, result.MaxRatio))
}
if coinWan > 0 && result.FinalRatio > 0 {
result.CoinBasePrice = RoundMoney(coinWan / result.FinalRatio)
}
result.ItemPrice, result.Items = calculateItemLines(cfg, input)
result.TotalPrice = RoundMoney(result.CoinBasePrice + result.ItemPrice)
result.Valid = referenceRatio > 0
if coinM <= 0 {
result.Message = "请输入哈弗币数量"
}
return result
}
func insuranceBaseRatio(items []InsuranceBaseRatio, insurance string) (float64, bool) {
insurance = strings.TrimSpace(insurance)
for _, item := range items {
if item.Insurance == insurance {
return item.Ratio, true
}
}
return 0, false
}
func calculateConfigPenalty(items []RatioConfigItem, states map[string]bool) float64 {
penalty := 0.0
for _, item := range items {
if states[item.Key] {
continue
}
penalty += item.MissingPenalty
}
return RoundRatio(penalty)
}
// coinCorrection 命中最高阈值档,且使用严格大于,与主站发布逻辑保持一致。
func coinCorrection(items []CoinCorrection, coinM float64) float64 {
bestThreshold := -1.0
correction := 0.0
for _, item := range items {
if coinM > item.ThresholdM && item.ThresholdM >= bestThreshold {
bestThreshold = item.ThresholdM
correction = item.Correction
}
}
return correction
}
func dailyLossRatioAdjustment(dailyLossM float64) float64 {
if dailyLossM <= 0 {
return 0
}
adjustment := (dailyLossM - 10) / 10
if adjustment < 0 {
return 0
}
floored := math.Floor(adjustment)
if floored > 4 {
return 4
}
return floored
}
func clampSaleRatio(requested, reference, maxRatio float64) float64 {
if requested < reference {
return reference
}
if requested > maxRatio {
return maxRatio
}
return requested
}
func calculateItemLines(config Config, input Input) (float64, []ItemLine) {
lines := make([]ItemLine, 0, len(config.QuantityItems))
rawTotal := 0.0
for _, item := range config.QuantityItems {
quantity := math.Max(input.Quantities[item.Key], 0)
unitPrice := ParseUnitPrice(item.Price)
free := input.FreeItems[item.Key]
disabled := isItemDisabledForInsurance(item, input.Insurance)
amount := 0.0
if !disabled && !free && quantity > 0 && unitPrice > 0 {
rawTotal += quantity * unitPrice
amount = RoundMoney(quantity * unitPrice)
}
lines = append(lines, ItemLine{
Key: item.Key,
Label: item.Label,
Quantity: quantity,
Unit: item.Unit,
UnitPrice: unitPrice,
Amount: amount,
Free: free,
Disabled: disabled,
})
}
return RoundMoney(rawTotal), lines
}
+240
View File
@@ -0,0 +1,240 @@
package pricing
import (
"math"
"testing"
)
func TestScreenshotExample(t *testing.T) {
config := DefaultConfig()
result := config.Calculate(Input{
CoinM: 226,
Insurance: "3*3",
States: map[string]bool{},
DailyLossM: 10,
})
if result.ReferenceRatio != 46 {
t.Fatalf("reference ratio = %v, want 46", result.ReferenceRatio)
}
if result.FinalRatio != 46 {
t.Fatalf("final ratio = %v, want 46", result.FinalRatio)
}
if result.CoinBasePrice != 491.3 {
t.Fatalf("coin base price = %v, want 491.3", result.CoinBasePrice)
}
if result.ItemPrice != 0 {
t.Fatalf("item price = %v, want 0", result.ItemPrice)
}
if result.TotalPrice != 491.3 {
t.Fatalf("total price = %v, want 491.3", result.TotalPrice)
}
}
func TestAllStatesMatched(t *testing.T) {
config := DefaultConfig()
result := config.Calculate(Input{
CoinM: 100,
Insurance: "3*3",
States: map[string]bool{
"melee": true,
"operatorRed": true,
"staminaMax": true,
"loadMax": true,
"weapon": true,
},
DailyLossM: 10,
})
if result.ReferenceRatio != 40 {
t.Fatalf("reference ratio = %v, want 40", result.ReferenceRatio)
}
if result.CoinBasePrice != 250 {
t.Fatalf("coin base price = %v, want 250", result.CoinBasePrice)
}
}
func TestMissingPenalty(t *testing.T) {
config := DefaultConfig()
result := config.Calculate(Input{
CoinM: 100,
Insurance: "3*3",
States: map[string]bool{
"melee": true,
"operatorRed": true,
"staminaMax": true,
"loadMax": true,
},
DailyLossM: 10,
})
if result.ConfigPenalty != 1 {
t.Fatalf("config penalty = %v, want 1", result.ConfigPenalty)
}
if result.ReferenceRatio != 41 {
t.Fatalf("reference ratio = %v, want 41", result.ReferenceRatio)
}
if result.CoinBasePrice != 243.9 {
t.Fatalf("coin base price = %v, want 243.9", result.CoinBasePrice)
}
}
func TestCoinCorrectionUsesHighestCrossedThreshold(t *testing.T) {
config := DefaultConfig()
cases := []struct {
coinM float64
want float64
}{
{130, 0},
{130.1, 1},
{230, 1},
{230.1, 2.5},
{380.1, 4},
{600, 5},
}
for _, item := range cases {
result := config.Calculate(Input{CoinM: item.coinM, Insurance: "3*3", DailyLossM: 10})
if result.CoinCorrection != item.want {
t.Fatalf("coinM=%v correction=%v, want %v", item.coinM, result.CoinCorrection, item.want)
}
}
}
func TestDailyLossAdjustment(t *testing.T) {
config := DefaultConfig()
cases := []struct {
loss float64
want float64
}{
{0, 0},
{10, 0},
{20, 1},
{30, 2},
{40, 3},
{50, 4},
{90, 4},
}
for _, item := range cases {
result := config.Calculate(Input{CoinM: 100, Insurance: "3*3", DailyLossM: item.loss})
if result.DailyLossAdjustment != item.want {
t.Fatalf("dailyLoss=%v adjustment=%v, want %v", item.loss, result.DailyLossAdjustment, item.want)
}
}
}
func TestAcceleratedRatioClamp(t *testing.T) {
config := DefaultConfig()
base := Input{CoinM: 226, Insurance: "3*3", DailyLossM: 10}
result := config.Calculate(withSaleRatio(base, 50))
if result.FinalRatio != 50 {
t.Fatalf("final ratio = %v, want 50", result.FinalRatio)
}
if result.MaxRatio != 56 {
t.Fatalf("max ratio = %v, want 56", result.MaxRatio)
}
result = config.Calculate(withSaleRatio(base, 100))
if result.FinalRatio != 56 {
t.Fatalf("clamped ratio = %v, want 56", result.FinalRatio)
}
result = config.Calculate(withSaleRatio(base, 30))
if result.FinalRatio != 46 {
t.Fatalf("ratio below reference = %v, want 46", result.FinalRatio)
}
}
func TestConsumablePriceParsing(t *testing.T) {
cases := []struct {
price string
want float64
}{
{"0.6元/发", 0.6},
{"6元/60发", 0.1},
{"2.5元/个", 2.5},
{"3元/张", 3},
{"2元", 2},
{"", 0},
}
for _, item := range cases {
if got := ParseUnitPrice(item.price); math.Abs(got-item.want) > 1e-9 {
t.Fatalf("ParseUnitPrice(%q)=%v, want %v", item.price, got, item.want)
}
}
}
func TestConsumablesAndFreeItems(t *testing.T) {
config := DefaultConfig()
result := config.Calculate(Input{
CoinM: 100,
Insurance: "3*3",
DailyLossM: 10,
States: map[string]bool{
"melee": true,
"operatorRed": true,
"staminaMax": true,
"loadMax": true,
"weapon": true,
},
Quantities: map[string]float64{
"awmAmmo": 10,
"helmet6": 2,
},
FreeItems: map[string]bool{
"helmet6": true,
},
})
if result.ItemPrice != 6 {
t.Fatalf("item price = %v, want 6", result.ItemPrice)
}
if result.TotalPrice != 256 {
t.Fatalf("total price = %v, want 256", result.TotalPrice)
}
}
func TestGridCardDisabledAtThreeByThree(t *testing.T) {
config := DefaultConfig()
result := config.Calculate(Input{
CoinM: 100,
Insurance: "3*3",
DailyLossM: 10,
Quantities: map[string]float64{"gridCard9": 3},
})
if result.ItemPrice != 0 {
t.Fatalf("item price = %v, want 0", result.ItemPrice)
}
for _, item := range result.Items {
if item.Key == "gridCard9" && !item.Disabled {
t.Fatal("gridCard9 should be disabled at 3*3")
}
}
result = config.Calculate(Input{
CoinM: 100,
Insurance: "2*3",
DailyLossM: 10,
Quantities: map[string]float64{"gridCard9": 3},
})
if result.ItemPrice != 9 {
t.Fatalf("item price at 2*3 = %v, want 9", result.ItemPrice)
}
}
func TestNormalizeRestoresEmptySections(t *testing.T) {
normalized := Normalize(Config{})
if len(normalized.InsuranceBaseRatios) == 0 {
t.Fatal("insurance ratios should fall back to defaults")
}
if len(normalized.RatioConfigItems) == 0 {
t.Fatal("ratio config items should fall back to defaults")
}
if len(normalized.QuantityItems) == 0 {
t.Fatal("quantity items should fall back to defaults")
}
if normalized.AcceleratedMaxRatio != 10 {
t.Fatalf("accelerated max = %v, want 10", normalized.AcceleratedMaxRatio)
}
}
func withSaleRatio(input Input, ratio float64) Input {
input.SaleRatio = ratio
return input
}
+206
View File
@@ -0,0 +1,206 @@
package pricing
import (
"math"
"regexp"
"sort"
"strconv"
"strings"
)
var (
fractionPricePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*元\s*/\s*(\d+(?:\.\d+)?)`)
singlePricePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*元`)
)
// Normalize 保证配置始终可计算,并修补线上同步/手工编辑产生的空字段。
func Normalize(config Config) Config {
defaults := DefaultConfig()
config.SchemaVersion = 1
insurances := make([]InsuranceBaseRatio, 0, len(config.InsuranceBaseRatios))
seenInsurance := make(map[string]struct{}, len(config.InsuranceBaseRatios))
for _, item := range config.InsuranceBaseRatios {
item.Insurance = strings.TrimSpace(item.Insurance)
if item.Insurance == "" || !isFinitePositive(item.Ratio) {
continue
}
if _, exists := seenInsurance[item.Insurance]; exists {
continue
}
seenInsurance[item.Insurance] = struct{}{}
item.Ratio = RoundRatio(item.Ratio)
insurances = append(insurances, item)
}
if len(insurances) == 0 {
insurances = defaults.InsuranceBaseRatios
}
config.InsuranceBaseRatios = insurances
ratioItems := make([]RatioConfigItem, 0, len(config.RatioConfigItems))
seenRatioKey := make(map[string]struct{}, len(config.RatioConfigItems))
for _, item := range config.RatioConfigItems {
item.Key = strings.TrimSpace(item.Key)
item.Label = strings.TrimSpace(item.Label)
item.Kind = strings.TrimSpace(item.Kind)
item.GroupKey = strings.TrimSpace(item.GroupKey)
if item.Key == "" || item.Label == "" || !isFiniteNonNegative(item.MissingPenalty) {
continue
}
if _, exists := seenRatioKey[item.Key]; exists {
continue
}
seenRatioKey[item.Key] = struct{}{}
configKind := item.Kind
if configKind != "skin_group" && configKind != "max_stamina" && configKind != "max_load" {
configKind = "skin_group"
}
item.Kind = configKind
item.MissingPenalty = RoundRatio(item.MissingPenalty)
ratioItems = append(ratioItems, item)
}
if len(ratioItems) == 0 {
ratioItems = defaults.RatioConfigItems
}
config.RatioConfigItems = ratioItems
corrections := make([]CoinCorrection, 0, len(config.CoinCorrections))
for _, item := range config.CoinCorrections {
if !isFiniteNonNegative(item.ThresholdM) || !isFiniteNonNegative(item.Correction) {
continue
}
item.ThresholdM = RoundRatio(item.ThresholdM)
item.Correction = RoundRatio(item.Correction)
corrections = append(corrections, item)
}
if len(corrections) == 0 {
corrections = defaults.CoinCorrections
}
sort.SliceStable(corrections, func(i, j int) bool {
return corrections[i].ThresholdM < corrections[j].ThresholdM
})
config.CoinCorrections = corrections
quantityItems := make([]QuantityItem, 0, len(config.QuantityItems))
seenQuantityKey := make(map[string]struct{}, len(config.QuantityItems))
for _, item := range config.QuantityItems {
item.Key = strings.TrimSpace(item.Key)
item.Label = strings.TrimSpace(item.Label)
item.Price = strings.TrimSpace(item.Price)
item.Unit = strings.TrimSpace(item.Unit)
if item.Key == "" || item.Label == "" || item.Price == "" {
continue
}
if _, exists := seenQuantityKey[item.Key]; exists {
continue
}
seenQuantityKey[item.Key] = struct{}{}
if item.Unit == "" {
item.Unit = defaultUnit(item.Key, item.Label)
}
quantityItems = append(quantityItems, item)
}
if len(quantityItems) == 0 {
quantityItems = defaults.QuantityItems
}
config.QuantityItems = quantityItems
lossOptions := make([]float64, 0, len(config.DailyLossOptions))
seenLoss := make(map[float64]struct{}, len(config.DailyLossOptions))
for _, value := range config.DailyLossOptions {
if !isFinitePositive(value) {
continue
}
value = RoundRatio(value)
if _, exists := seenLoss[value]; exists {
continue
}
seenLoss[value] = struct{}{}
lossOptions = append(lossOptions, value)
}
if len(lossOptions) == 0 {
lossOptions = defaults.DailyLossOptions
}
sort.Float64s(lossOptions)
config.DailyLossOptions = lossOptions
if !isFinitePositive(config.AcceleratedMaxRatio) {
config.AcceleratedMaxRatio = defaults.AcceleratedMaxRatio
}
config.AcceleratedMaxRatio = math.Min(RoundRatio(config.AcceleratedMaxRatio), 50)
config.SyncURL = strings.TrimRight(strings.TrimSpace(config.SyncURL), "/")
if config.SyncURL == "" {
config.SyncURL = defaults.SyncURL
}
return config
}
// ParseUnitPrice 解析「0.6元/发」「6元/60发」这类单价文本,返回单个物品的价格。
func ParseUnitPrice(price string) float64 {
normalized := strings.ReplaceAll(strings.TrimSpace(price), "/", "/")
normalized = strings.ReplaceAll(normalized, ",", ",")
if match := fractionPricePattern.FindStringSubmatch(normalized); len(match) == 3 {
amount := parseFloat(match[1])
count := parseFloat(match[2])
if amount > 0 && count > 0 {
return amount / count
}
}
if match := singlePricePattern.FindStringSubmatch(normalized); len(match) == 2 {
return math.Max(parseFloat(match[1]), 0)
}
return 0
}
func RoundMoney(value float64) float64 {
return math.Round(value*10) / 10
}
func RoundRatio(value float64) float64 {
return math.Round(value*10) / 10
}
func isItemDisabledForInsurance(item QuantityItem, insurance string) bool {
if item.Disabled {
return true
}
if insurance != "3*3" {
return false
}
return item.Key == "gridCard9" ||
strings.Contains(item.Label, "9格") ||
strings.Contains(item.Label, "顶级保险卡")
}
func defaultUnit(key, label string) string {
switch key {
case "awmAmmo", "level6Ammo", "barrettAmmo":
return "发"
case "gridCard9":
return "张"
}
if strings.Contains(label, "子弹") {
return "发"
}
if strings.Contains(label, "卡") {
return "张"
}
return "个"
}
func parseFloat(value string) float64 {
result, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || math.IsNaN(result) || math.IsInf(result, 0) {
return 0
}
return result
}
func isFinitePositive(value float64) bool {
return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func isFiniteNonNegative(value float64) bool {
return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
}
+42
View File
@@ -0,0 +1,42 @@
package pricing
const DefaultSyncURL = "https://hao858.com"
func DefaultConfig() Config {
return Config{
SchemaVersion: 1,
InsuranceBaseRatios: []InsuranceBaseRatio{
{Insurance: "3*3", Ratio: 40},
{Insurance: "2*3", Ratio: 42},
{Insurance: "2*2", Ratio: 47},
{Insurance: "2*1", Ratio: 48},
},
RatioConfigItems: []RatioConfigItem{
{Key: "melee", Label: "刀皮", Kind: "skin_group", GroupKey: "melee", MissingPenalty: 1},
{Key: "operatorRed", Label: "角色红皮", Kind: "skin_group", GroupKey: "operatorRed", MissingPenalty: 1},
{Key: "staminaMax", Label: "满体力", Kind: "max_stamina", MissingPenalty: 1},
{Key: "loadMax", Label: "满负重", Kind: "max_load", MissingPenalty: 1},
{Key: "weapon", Label: "武器皮肤", Kind: "skin_group", GroupKey: "weapon", MissingPenalty: 1},
},
CoinCorrections: []CoinCorrection{
{ThresholdM: 130, Correction: 1},
{ThresholdM: 230, Correction: 2.5},
{ThresholdM: 380, Correction: 4},
{ThresholdM: 530, Correction: 5},
},
QuantityItems: []QuantityItem{
{Key: "awmAmmo", Label: "AWM子弹", Price: "0.6元/发", Unit: "发"},
{Key: "level6Ammo", Label: "6级子弹", Price: "0.1元/发", Unit: "发", Placeholder: "按单发数量填写,60发=6元"},
{Key: "helmet6", Label: "6级头盔", Price: "1.5元/个", Unit: "个"},
{Key: "armor6", Label: "6级护甲", Price: "2.5元/个", Unit: "个"},
{Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发", Unit: "发"},
{Key: "gridCard9", Label: "顶级保险卡", Price: "3元/张", Unit: "张", Placeholder: "3*3 保险下不可用"},
{Key: "coffeeBean", Label: "高级咖啡豆", Price: "2.5元/个", Unit: "个"},
{Key: "bulletPart", Label: "高级子弹零件", Price: "3元/个", Unit: "个"},
{Key: "redPacket45", Label: "45格大红包", Price: "1.5元/个", Unit: "个"},
},
DailyLossOptions: []float64{10, 20, 30, 40, 50},
AcceleratedMaxRatio: 10,
SyncURL: DefaultSyncURL,
}
}
+76
View File
@@ -0,0 +1,76 @@
package pricing
type InsuranceBaseRatio struct {
Insurance string `json:"insurance"`
Ratio float64 `json:"ratio"`
}
type RatioConfigItem struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
GroupKey string `json:"group_key,omitempty"`
MissingPenalty float64 `json:"missing_penalty"`
}
type CoinCorrection struct {
ThresholdM float64 `json:"threshold_m"`
Correction float64 `json:"correction"`
}
type QuantityItem struct {
Key string `json:"key"`
Label string `json:"label"`
Price string `json:"price"`
Unit string `json:"unit"`
Placeholder string `json:"placeholder,omitempty"`
Disabled bool `json:"disabled"`
}
type Config struct {
SchemaVersion int `json:"schema_version"`
InsuranceBaseRatios []InsuranceBaseRatio `json:"insurance_base_ratios"`
RatioConfigItems []RatioConfigItem `json:"ratio_config_items"`
CoinCorrections []CoinCorrection `json:"coin_corrections"`
QuantityItems []QuantityItem `json:"quantity_items"`
DailyLossOptions []float64 `json:"daily_loss_options"`
AcceleratedMaxRatio float64 `json:"accelerated_max_ratio"`
SyncURL string `json:"sync_url"`
}
type Input struct {
CoinM float64 `json:"coin_m"`
Insurance string `json:"insurance"`
States map[string]bool `json:"states"`
DailyLossM float64 `json:"daily_loss_m"`
SaleRatio float64 `json:"sale_ratio"`
Quantities map[string]float64 `json:"quantities"`
FreeItems map[string]bool `json:"free_items"`
}
type ItemLine struct {
Key string `json:"key"`
Label string `json:"label"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
UnitPrice float64 `json:"unit_price"`
Amount float64 `json:"amount"`
Free bool `json:"free"`
Disabled bool `json:"disabled"`
}
type Result struct {
ReferenceRatio float64 `json:"reference_ratio"`
FinalRatio float64 `json:"final_ratio"`
MaxRatio float64 `json:"max_ratio"`
InsuranceBaseRatio float64 `json:"insurance_base_ratio"`
ConfigPenalty float64 `json:"config_penalty"`
CoinCorrection float64 `json:"coin_correction"`
DailyLossAdjustment float64 `json:"daily_loss_adjustment"`
CoinBasePrice float64 `json:"coin_base_price"`
ItemPrice float64 `json:"item_price"`
TotalPrice float64 `json:"total_price"`
Items []ItemLine `json:"items"`
Valid bool `json:"valid"`
Message string `json:"message,omitempty"`
}