555 lines
17 KiB
Go
555 lines
17 KiB
Go
package world
|
|
|
|
import (
|
|
"bd2server/internal/server/design/gamedata"
|
|
"bd2server/internal/server/domain/command"
|
|
"bd2server/internal/server/domain/world/progress"
|
|
"bd2server/internal/server/platform/versionconfig"
|
|
"bd2server/internal/server/protocol/wire"
|
|
"bd2server/internal/server/storage/stateio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
type SkyWaySchedule struct {
|
|
Group, Bonus uint64
|
|
Days []uint64
|
|
}
|
|
type skyWayEconomy interface {
|
|
CanApply(command.Context, []gamedata.Reward) error
|
|
Apply(command.Context, string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
|
AdditionalCurrencies(command.Context) (map[int]uint64, error)
|
|
}
|
|
type skyWayAP interface {
|
|
HuntingAP(command.Context) (uint64, uint64, error)
|
|
}
|
|
type skyWayRuntime struct {
|
|
design *gamedata.SkyWayDesign
|
|
store stateio.Store
|
|
economy skyWayEconomy
|
|
ap skyWayAP
|
|
schedules []SkyWaySchedule
|
|
now func() time.Time
|
|
graph *gamedata.RewardGraph
|
|
bossProgress func(command.Context, uint64, uint64) error
|
|
battleAttempt func(command.Context, uint64) error
|
|
}
|
|
type skyWayGroup struct {
|
|
Current uint64
|
|
Maximum int
|
|
Auto bool
|
|
}
|
|
type skyWayRun struct {
|
|
Group, ID, Generation uint64
|
|
Defeated []uint64
|
|
}
|
|
type skyWayReceipt struct {
|
|
Request []byte
|
|
Response, Bonus []byte
|
|
Monsters [][]byte
|
|
}
|
|
type skyWayState struct {
|
|
Version string `json:"version"`
|
|
Groups map[string]skyWayGroup `json:"groups"`
|
|
Run skyWayRun `json:"run"`
|
|
Receipts map[string]skyWayReceipt `json:"receipts"`
|
|
}
|
|
|
|
func (s *Service) AttachSkyWay(ctx command.Context, d *gamedata.SkyWayDesign, store stateio.Store, economy skyWayEconomy, ap skyWayAP, schedules []SkyWaySchedule, graph *gamedata.RewardGraph) error {
|
|
if d == nil || store == nil || economy == nil || ap == nil || graph == nil {
|
|
return fmt.Errorf("world: invalid SkyWay runtime")
|
|
}
|
|
seen := map[uint64]bool{}
|
|
for _, r := range schedules {
|
|
if r.Group == 0 || r.Group > 7 || seen[r.Group] || r.Bonus > math.MaxInt32 {
|
|
return fmt.Errorf("world: invalid SkyWay schedule")
|
|
}
|
|
seen[r.Group] = true
|
|
days := map[uint64]bool{}
|
|
for _, day := range r.Days {
|
|
if day > 6 || days[day] {
|
|
return fmt.Errorf("world: invalid SkyWay bonus day")
|
|
}
|
|
days[day] = true
|
|
}
|
|
}
|
|
for _, row := range d.Stages {
|
|
if !seen[row.Group] {
|
|
return fmt.Errorf("world: missing SkyWay group schedule")
|
|
}
|
|
}
|
|
s.skyway = &skyWayRuntime{design: d, store: store, economy: economy, ap: ap, schedules: schedules, now: time.Now, graph: graph}
|
|
_, err := s.skyway.load(ctx)
|
|
return err
|
|
}
|
|
func (r *skyWayRuntime) load(ctx command.Context) (skyWayState, error) {
|
|
v := skyWayState{Version: versionconfig.State(), Groups: map[string]skyWayGroup{}, Receipts: map[string]skyWayReceipt{}}
|
|
b, err := r.store.Load(ctx.State, "skyway")
|
|
if err != nil || b == nil {
|
|
return v, err
|
|
}
|
|
if err = stateio.RequireExactJSONObject(b, "version", "groups", "run", "receipts"); err != nil {
|
|
return v, err
|
|
}
|
|
d := json.NewDecoder(bytes.NewReader(b))
|
|
d.DisallowUnknownFields()
|
|
if err = d.Decode(&v); err != nil {
|
|
return v, err
|
|
}
|
|
if v.Version != versionconfig.State() || v.Groups == nil || v.Receipts == nil {
|
|
return v, fmt.Errorf("world: incompatible SkyWay state")
|
|
}
|
|
for k, st := range v.Groups {
|
|
g, e := strconv.ParseUint(k, 10, 64)
|
|
stage, ok := r.design.Stage(g, st.Current)
|
|
maximum := -1
|
|
for _, row := range r.design.Stages {
|
|
if row.Group == g {
|
|
maximum = max(maximum, int(row.Difficulty))
|
|
}
|
|
}
|
|
if e != nil || strconv.FormatUint(g, 10) != k || !ok || st.Maximum < -1 || st.Maximum > maximum || int(stage.Difficulty) > st.Maximum+1 {
|
|
return v, fmt.Errorf("world: invalid SkyWay group progression")
|
|
}
|
|
}
|
|
if v.Run.Group != 0 {
|
|
stage, ok := r.design.Stage(v.Run.Group, v.Run.ID)
|
|
if !ok || v.Run.Generation == 0 {
|
|
return v, fmt.Errorf("world: invalid SkyWay run")
|
|
}
|
|
seen := map[uint64]bool{}
|
|
for _, id := range v.Run.Defeated {
|
|
if !slices.Contains(stage.Monsters, id) || seen[id] {
|
|
return v, fmt.Errorf("world: invalid SkyWay defeated monster")
|
|
}
|
|
seen[id] = true
|
|
}
|
|
}
|
|
return v, nil
|
|
}
|
|
func (r *skyWayRuntime) save(ctx command.Context, v skyWayState) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.store.Save(ctx.State, "skyway", b)
|
|
}
|
|
func (s *Service) skyWayPack(ctx command.Context, pack int) error {
|
|
if s.skyway == nil || pack != s.skyway.design.Pack || !s.packUnlocked(ctx, pack) {
|
|
return fmt.Errorf("world: SkyWay pack unavailable")
|
|
}
|
|
return nil
|
|
}
|
|
func (r *skyWayRuntime) costs(ctx command.Context, stage gamedata.SkyWayStage, count uint64) ([]gamedata.Reward, error) {
|
|
if count == 0 {
|
|
return nil, nil
|
|
}
|
|
var free, bonus uint64
|
|
var err error
|
|
freeType, bonusType := uint64(21), uint64(23)
|
|
if stage.APType == 1 {
|
|
free, bonus, err = r.ap.HuntingAP(ctx)
|
|
} else {
|
|
freeType, bonusType = 32, 33
|
|
var values map[int]uint64
|
|
values, err = r.economy.AdditionalCurrencies(ctx)
|
|
free, bonus = values[36], values[37]
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if free < count && bonus < count-free {
|
|
return nil, fmt.Errorf("world: insufficient SkyWay AP")
|
|
}
|
|
n := min(free, count)
|
|
var out []gamedata.Reward
|
|
if n > 0 {
|
|
out = append(out, gamedata.Reward{Type: freeType, Count: n})
|
|
}
|
|
if n < count {
|
|
out = append(out, gamedata.Reward{Type: bonusType, Count: count - n})
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *Service) handleSkyWay(ctx command.Context, path string, request []byte) (int, []byte, bool, error) {
|
|
if path == "/SaveUserPosition" && s.skyway != nil {
|
|
pack, err := requestPack(request)
|
|
if err != nil {
|
|
return 7, nil, true, err
|
|
}
|
|
if pack == s.skyway.design.Pack {
|
|
raw, found, e := wire.Bytes(request, 3)
|
|
var position progress.Position
|
|
if e != nil || !found || json.Unmarshal(raw, &position) != nil || !s.skyway.design.SafeMaps[position.MapID] || !s.packUnlocked(ctx, pack) {
|
|
return 7, nil, true, ErrInvalidRequest
|
|
}
|
|
if e = s.state.SaveUserPosition(ctx, request); e != nil {
|
|
return 7, nil, true, e
|
|
}
|
|
v, e := s.skyway.load(ctx)
|
|
if e != nil {
|
|
return 7, nil, true, e
|
|
}
|
|
v.Run = skyWayRun{Generation: v.Run.Generation}
|
|
e = s.skyway.save(ctx, v)
|
|
return 7, nil, true, e
|
|
}
|
|
}
|
|
code := map[string]int{"/SkyWayInfo": 160, "/SkyWayEnter": 161, "/SkyWayScheduleInfo": 162}[path]
|
|
if code == 0 {
|
|
return 0, nil, false, nil
|
|
}
|
|
if s.skyway == nil {
|
|
return code, nil, true, fmt.Errorf("world: SkyWay runtime missing")
|
|
}
|
|
seq, present, err := wire.Varint(request, 1)
|
|
if err != nil || !present || seq == 0 || seq > math.MaxInt32 {
|
|
return code, nil, true, ErrInvalidRequest
|
|
}
|
|
r := s.skyway
|
|
if path == "/SkyWayScheduleInfo" {
|
|
var out []byte
|
|
for _, schedule := range r.schedules {
|
|
row := wire.AppendVarint(nil, 1, schedule.Group)
|
|
for _, day := range schedule.Days {
|
|
row = wire.AppendVarint(row, 2, day)
|
|
}
|
|
if schedule.Bonus > 0 {
|
|
row = wire.AppendVarint(row, 3, schedule.Bonus)
|
|
}
|
|
out = wire.AppendBytes(out, 1, row)
|
|
}
|
|
return code, out, true, nil
|
|
}
|
|
pack, err := requestPack(request)
|
|
if err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
if err = s.skyWayPack(ctx, pack); err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
v, err := r.load(ctx)
|
|
if err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
if path == "/SkyWayInfo" {
|
|
var out []byte
|
|
seen := map[uint64]bool{}
|
|
for _, stage := range r.design.Stages {
|
|
if seen[stage.Group] {
|
|
continue
|
|
}
|
|
seen[stage.Group] = true
|
|
st, ok := v.Groups[strconv.FormatUint(stage.Group, 10)]
|
|
if !ok {
|
|
st = skyWayGroup{Current: stage.ID, Maximum: -1}
|
|
}
|
|
row := wire.AppendVarint(nil, 2, stage.Group)
|
|
row = wire.AppendVarint(row, 3, st.Current)
|
|
row = wire.AppendVarint(row, 4, uint64(int64(st.Maximum)))
|
|
if st.Auto {
|
|
row = wire.AppendVarint(row, 1, 1)
|
|
}
|
|
out = wire.AppendBytes(out, 1, row)
|
|
}
|
|
return code, out, true, nil
|
|
}
|
|
if ctx.SessionID == "" {
|
|
return code, nil, true, ErrInvalidRequest
|
|
}
|
|
key := "enter:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10)
|
|
if prior, ok := v.Receipts[key]; ok {
|
|
if !bytes.Equal(prior.Request, request) {
|
|
return code, nil, true, ErrInvalidRequest
|
|
}
|
|
return code, prior.Response, true, nil
|
|
}
|
|
group, _, err := wire.Varint(request, 3)
|
|
if err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
id, _, err := wire.Varint(request, 4)
|
|
if err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
auto, _, err := wire.Varint(request, 5)
|
|
if err != nil || auto > 1 {
|
|
return code, nil, true, ErrInvalidRequest
|
|
}
|
|
stage, ok := r.design.Stage(group, id)
|
|
if !ok {
|
|
return code, nil, true, ErrInvalidRequest
|
|
}
|
|
current, err := s.CurrentPackID(ctx)
|
|
if err != nil || current != pack || s.battleActive != nil && s.battleActive(ctx) {
|
|
return code, nil, true, fmt.Errorf("world: SkyWay enter outside current pack or during battle")
|
|
}
|
|
st, exists := v.Groups[strconv.FormatUint(group, 10)]
|
|
if !exists {
|
|
st.Maximum = -1
|
|
}
|
|
if int(stage.Difficulty) > st.Maximum+1 {
|
|
return code, nil, true, fmt.Errorf("world: SkyWay difficulty requires previous boss clear")
|
|
}
|
|
if _, err = r.costs(ctx, stage, 1); err != nil {
|
|
return code, nil, true, err
|
|
}
|
|
if v.Run.Generation == math.MaxUint64 {
|
|
return code, nil, true, fmt.Errorf("world: SkyWay generation overflow")
|
|
}
|
|
v.Run = skyWayRun{Group: group, ID: id, Generation: v.Run.Generation + 1}
|
|
st.Current, st.Auto = id, auto == 1
|
|
v.Groups[strconv.FormatUint(group, 10)] = st
|
|
var out []byte
|
|
for _, row := range r.monsters(stage, v.Run) {
|
|
out = wire.AppendBytes(out, 1, row)
|
|
}
|
|
v.Receipts[key] = skyWayReceipt{Request: slices.Clone(request), Response: out}
|
|
err = r.save(ctx, v)
|
|
return code, out, true, err
|
|
}
|
|
func (r *skyWayRuntime) monsters(stage gamedata.SkyWayStage, run skyWayRun) [][]byte {
|
|
var out [][]byte
|
|
for _, id := range stage.Monsters {
|
|
out = append(out, r.monster(id, !slices.Contains(run.Defeated, id)))
|
|
}
|
|
if len(run.Defeated) == len(stage.Monsters) {
|
|
out = append(out, r.monster(stage.Boss, true))
|
|
}
|
|
return out
|
|
}
|
|
func (r *skyWayRuntime) monster(id uint64, active bool) []byte {
|
|
m := r.design.Monsters[id]
|
|
out := wire.AppendVarint(nil, 1, id)
|
|
out = wire.AppendVarint(out, 2, m.Decks[0])
|
|
if active {
|
|
out = wire.AppendVarint(out, 6, 1)
|
|
}
|
|
return out
|
|
}
|
|
func (s *Service) SkyWayBeginBattle(ctx command.Context, pack int, mode, monster, deck uint64) (string, error) {
|
|
if err := s.skyWayPack(ctx, pack); err != nil {
|
|
return "", err
|
|
}
|
|
v, err := s.skyway.load(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
stage, ok := s.skyway.design.Stage(v.Run.Group, v.Run.ID)
|
|
if !ok || mode != gamedata.SkyWayMode(stage.Group) || slices.Contains(v.Run.Defeated, monster) {
|
|
return "", fmt.Errorf("world: invalid SkyWay encounter")
|
|
}
|
|
cost := uint64(0)
|
|
member := false
|
|
for i, id := range stage.Monsters {
|
|
if id == monster {
|
|
member = true
|
|
cost = stage.AP[i]
|
|
}
|
|
}
|
|
if monster == stage.Boss {
|
|
member = len(v.Run.Defeated) == len(stage.Monsters)
|
|
cost = stage.BossAP
|
|
}
|
|
if !member || !slices.Contains(s.skyway.design.Monsters[monster].Decks, deck) {
|
|
return "", fmt.Errorf("world: SkyWay monster/deck unavailable")
|
|
}
|
|
if _, err = s.skyway.costs(ctx, stage, cost); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("skyway:%d:%d:%d:%d", stage.Group, stage.ID, v.Run.Generation, monster), nil
|
|
}
|
|
func (s *Service) SkyWayCompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, instance, receipt string) ([]byte, []byte, [][]byte, error) {
|
|
r := s.skyway
|
|
if r == nil || receipt == "" {
|
|
return nil, nil, nil, ErrInvalidRequest
|
|
}
|
|
v, err := r.load(ctx)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
fingerprint := fmt.Sprintf("%d:%d:%d:%d:%s", pack, mode, monster, deck, instance)
|
|
key := "win:" + receipt
|
|
if old, ok := v.Receipts[key]; ok {
|
|
if !bytes.Equal(old.Request, []byte(fingerprint)) {
|
|
return nil, nil, nil, ErrInvalidRequest
|
|
}
|
|
return old.Response, old.Bonus, old.Monsters, nil
|
|
}
|
|
current, err := s.SkyWayBeginBattle(ctx, pack, mode, monster, deck)
|
|
if err != nil || current != instance {
|
|
return nil, nil, nil, fmt.Errorf("world: stale SkyWay battle: %w", err)
|
|
}
|
|
stage, _ := r.design.Stage(v.Run.Group, v.Run.ID)
|
|
cost := stage.BossAP
|
|
if monster == stage.Boss && v.Run.Generation == math.MaxUint64 {
|
|
return nil, nil, nil, ErrInvalidRequest
|
|
}
|
|
for i, id := range stage.Monsters {
|
|
if id == monster {
|
|
cost = stage.AP[i]
|
|
}
|
|
}
|
|
costs, err := r.costs(ctx, stage, cost)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
resolved, err := r.graph.Resolve(r.design.Monsters[monster].Rewards[deck])
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
rewards := []gamedata.Reward{}
|
|
for _, reward := range resolved {
|
|
rewards = append(rewards, gamedata.Reward(reward))
|
|
}
|
|
var extra []gamedata.Reward
|
|
rate := r.bonusRate(stage.Group)
|
|
if rate > 0 {
|
|
for _, reward := range rewards {
|
|
if reward.Count > math.MaxInt32/rate {
|
|
return nil, nil, nil, fmt.Errorf("world: SkyWay bonus overflow")
|
|
}
|
|
reward.Count = reward.Count * rate / 100
|
|
if reward.Count > 0 {
|
|
extra = append(extra, reward)
|
|
}
|
|
}
|
|
}
|
|
bundle, err := r.economy.Apply(ctx, instance, costs, rewards)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
var bonus []byte
|
|
if len(extra) > 0 {
|
|
bonus, err = r.economy.Apply(ctx, instance+":bonus", nil, extra)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
}
|
|
updates := [][]byte{r.monster(monster, false)}
|
|
if monster == stage.Boss {
|
|
if r.bossProgress != nil {
|
|
if err = r.bossProgress(ctx, stage.PositionGroup, stage.Difficulty); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
}
|
|
st := v.Groups[strconv.FormatUint(stage.Group, 10)]
|
|
st.Maximum = max(st.Maximum, int(stage.Difficulty))
|
|
v.Groups[strconv.FormatUint(stage.Group, 10)] = st
|
|
v.Run.Generation++
|
|
v.Run.Defeated = nil
|
|
for _, id := range stage.Monsters {
|
|
updates = append(updates, r.monster(id, true))
|
|
}
|
|
} else {
|
|
v.Run.Defeated = append(v.Run.Defeated, monster)
|
|
if len(v.Run.Defeated) == len(stage.Monsters) {
|
|
updates = append(updates, r.monster(stage.Boss, true))
|
|
}
|
|
}
|
|
v.Receipts[key] = skyWayReceipt{Request: []byte(fingerprint), Response: bundle, Bonus: bonus, Monsters: updates}
|
|
if err = r.save(ctx, v); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return bundle, bonus, updates, nil
|
|
}
|
|
func (r *skyWayRuntime) bonusRate(group uint64) uint64 {
|
|
day := uint64(r.now().UTC().Weekday())
|
|
for _, schedule := range r.schedules {
|
|
if schedule.Group == group && slices.Contains(schedule.Days, day) {
|
|
return schedule.Bonus
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
func (s *Service) SkyWayDispatchEligibility(ctx command.Context, d *gamedata.DispatchDesign) error {
|
|
if err := s.skyWayPack(ctx, int(d.Pack)); err != nil {
|
|
return err
|
|
}
|
|
if !s.packCompleteFor(int(d.Pack)) {
|
|
return fmt.Errorf("world: SkyWay dispatch requires completed introductory quests")
|
|
}
|
|
stage, ok := s.skyway.design.Stage(d.TypeGroup, d.TypeID)
|
|
if !ok {
|
|
return ErrInvalidRequest
|
|
}
|
|
v, err := s.skyway.load(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st, ok := v.Groups[strconv.FormatUint(stage.Group, 10)]
|
|
if !ok || st.Maximum < int(stage.Difficulty) {
|
|
return fmt.Errorf("world: SkyWay dispatch requires selected boss clear")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) skyWayMap(ctx command.Context, pack int) (int, bool, error) {
|
|
if s.skyway == nil || pack != s.skyway.design.Pack {
|
|
return 0, false, nil
|
|
}
|
|
v, err := s.skyway.load(ctx)
|
|
if err != nil {
|
|
return 0, true, err
|
|
}
|
|
stage, ok := s.skyway.design.Stage(v.Run.Group, v.Run.ID)
|
|
return int(stage.Map), ok, nil
|
|
}
|
|
|
|
func (s *Service) SkyWayDispatchCosts(ctx command.Context, d *gamedata.DispatchDesign, count uint64) ([]gamedata.Reward, error) {
|
|
if s.skyway == nil {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
stage, ok := s.skyway.design.Stage(d.TypeGroup, d.TypeID)
|
|
if !ok || count > math.MaxInt32/d.AP {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
return s.skyway.costs(ctx, stage, d.AP*count)
|
|
}
|
|
|
|
func (s *Service) AttachSkyWayProgress(boss func(command.Context, uint64, uint64) error, attempt func(command.Context, uint64) error) {
|
|
s.skyway.bossProgress, s.skyway.battleAttempt = boss, attempt
|
|
}
|
|
func (s *Service) SkyWayBattleStarted(ctx command.Context, mode uint64, receipt string) error {
|
|
if !gamedata.IsSkyWayMode(mode) {
|
|
return nil
|
|
}
|
|
r := s.skyway
|
|
if r == nil || receipt == "" {
|
|
return ErrInvalidRequest
|
|
}
|
|
v, err := r.load(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := "attempt:" + receipt
|
|
if _, ok := v.Receipts[key]; ok {
|
|
return nil
|
|
}
|
|
if r.battleAttempt != nil {
|
|
if err = r.battleAttempt(ctx, mode-8); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
v.Receipts[key] = skyWayReceipt{Request: []byte(strconv.FormatUint(mode, 10))}
|
|
return r.save(ctx, v)
|
|
}
|
|
func (s *Service) SkyWayDispatchExchange(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
|
return s.skyway.economy.Apply(ctx, identity, costs, rewards)
|
|
}
|
|
func (s *Service) SkyWayDispatchBonus(d *gamedata.DispatchDesign, rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
|
|
rate := s.skyway.bonusRate(d.TypeGroup)
|
|
out := append([]gamedata.BattleReward(nil), rewards...)
|
|
for i, r := range out {
|
|
if r.Count > math.MaxInt32/(100+rate) {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
out[i].Count = r.Count * (100 + rate) / 100
|
|
}
|
|
return out, nil
|
|
}
|