Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.MD
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ AscNet is a research/private-server emulator for Punishing: Gray Raven. The runt

**Maintainer-authorized shop exception:** Curated retail `GetShopInfoResponse` catalogs MAY be stored in `Resources/Configs/client_shops.json` when the corresponding server-owned Shop/ShopGoods source is unavailable. This exception applies only to static shop catalog fields. Player purchase counts, refresh timestamps, reset periods, availability, and all other dynamic state MUST still be derived at runtime from persisted state and authoritative clock/table data; production MUST NOT read captures or decoded dump files.

**Maintainer-authorized purchase catalog exception:** The existing static supply-pack catalog MAY be curated into the server-owned `Resources/Configs/client_purchases.json`. The maintainer authorized retaining its static package definitions, removing inherited retail sales deadlines, and using authoritative reward-item icons when original artwork is unavailable. Prices, enabled state, and optional sales schedules belong to this catalog; player counts, purchase timestamps, entitlement durations/remaining days and claim state MUST remain derived from persisted player state and authoritative rules. Do not restore captured response envelopes or player-state fields. See `Resources/Configs/purchase-rules-source.md`.

**Maintainer-authorized Simulated Battlefield exception:** Curated retail `PreFightResponse.FightData.NpcGroupList` compositions and per-clear `FightSettleResponse.Settle.MultiRewardGoodsList` goods for Simulated Battlefield MAY be stored in `Resources/Configs/simulated_battlefield.json` when no server-owned encounter or drop source exists. This exception applies only to ordered static wave fields `NpcId` and `BufferIds`, plus static per-clear settlement fields `RewardType`, `TemplateId`, and `Count`. NPC levels, attributes, magic data, challenge counts, progression effects, timestamps, reward multiplicity, and all other dynamic state MUST still be derived at runtime from player/request state and authoritative table data; production MUST NOT read captures or decoded dump files.

For all game-protocol work:
Expand Down
34 changes: 34 additions & 0 deletions AscNet.Common/Database/Inventory.Currency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using AscNet.Common.MsgPack;

namespace AscNet.Common.Database;

public partial class Inventory
{
// The client displays item 2 + item 3 as one Black Card balance.
// Keep legacy constant names for compatibility with existing reward tables.
public static bool IsBlackCard(int id) => id is PaidGem or FreeGem;

public long SpendableCount(int id) => Items
.Where(item => IsBlackCard(id) ? IsBlackCard(item.Id) : item.Id == id)
.Sum(item => item.Count);

public List<Item> Spend(int id, int amount)
{
if (amount < 0 || SpendableCount(id) < amount)
throw new InvalidOperationException("Insufficient currency");
List<Item> changed = new();
foreach (int source in IsBlackCard(id) ? new[] { PaidGem, FreeGem } : new[] { id })
{
foreach (Item stack in Items.Where(item => item.Id == source))
{
long debit = Math.Min(amount, stack.Count);
if (debit <= 0) continue;
stack.Count -= debit;
stack.RefreshTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
amount -= checked((int)debit);
changed.Add(stack);
}
}
return changed;
}
}
32 changes: 32 additions & 0 deletions AscNet.Common/Database/Player.Purchases.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,47 @@ public partial class Player

[BsonElement("pending_purchase")]
public PlayerPendingPurchase? PendingPurchase { get; set; }

[BsonElement("purchase_daily_passes")]
[BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)]
public Dictionary<uint, PlayerPurchaseDailyPass> PurchaseDailyPasses { get; set; } = new();

[BsonElement("pending_recharge")]
public PlayerPendingRecharge? PendingRecharge { get; set; }

[BsonElement("recharge_sequence")]
public long RechargeSequence { get; set; }

[BsonElement("purchase_period_buy_times")]
[BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)]
public Dictionary<uint, int> PurchasePeriodBuyTimes { get; set; } = new();
}

public sealed class PlayerPurchaseDailyPass
{
public long EndDay { get; set; }
public long StartDay { get; set; }
public List<int> RewardIndexList { get; set; } = new();
public long LastClaimDay { get; set; } = -1;
}

public sealed class PlayerPendingRecharge
{
public string Key { get; set; } = "";
public string Order { get; set; } = "";
public int Count { get; set; }
}

public sealed class PlayerPendingPurchase
{
public uint Id { get; set; }
public int Count { get; set; }
public int PreviousBuyTimes { get; set; }
public int PeriodBuyTimes { get; set; }
public long BuyTime { get; set; }
public int ConsumeId { get; set; }
public int ConsumeCount { get; set; }
public List<AscNet.Common.MsgPack.RewardGoods> Goods { get; set; } = new();
[BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)]
public Dictionary<uint, PlayerPurchaseDailyPass> DailyPasses { get; set; } = new();
}
1 change: 1 addition & 0 deletions AscNet.Common/MsgPack/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,7 @@ public class GetPurchaseListResponsePurchaseInfoDailyRewardGoods
public dynamic? FirstRewardGoods { get; set; }
public dynamic? ExtraRewardGoods { get; set; }
public Int32 DailyRewardRemainDay { get; set; }
public Int32 BuyLimitRemainDay { get; set; }
public Boolean IsDailyRewardGet { get; set; }
public String Name { get; set; }
public String Desc { get; set; }
Expand Down
111 changes: 111 additions & 0 deletions AscNet.GameServer/Game/PurchaseCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using AscNet.Common.Util;
using Newtonsoft.Json.Linq;

namespace AscNet.GameServer.Game;

/// <summary>Server-owned package definitions, independent of player purchase state.</summary>
public sealed class PurchaseCatalog
{
private readonly Dictionary<uint, JObject> entries = new();
private static readonly string[] PlayerFields =
[
"BuyTimes", "LastBuyTime", "DailyRewardRemainDay", "BuyLimitRemainDay",
"IsDailyRewardGet", "DailyRewardSupplementGetData", "DiscountCouponInfos", "ConvertSwitch"
];

public static PurchaseCatalog Load(string path) => Parse(File.ReadAllText(path));

public static PurchaseCatalog Parse(string json)
{
JObject root = JObject.Parse(json);
if (root.Value<int>("SchemaVersion") != 1 || root["Purchases"] is not JArray purchases)
throw new InvalidDataException("Purchase catalog requires SchemaVersion 1 and Purchases; response snapshots are not supported.");
PurchaseCatalog catalog = new();
foreach (JToken token in purchases)
{
if (token is not JObject entry)
throw new InvalidDataException("Purchase catalog entries must be objects.");
uint id = entry.Value<uint>("Id");
if (id == 0 || id > int.MaxValue || !catalog.entries.TryAdd(id, entry))
throw new InvalidDataException($"Invalid or duplicate purchase Id {id}.");
if (entry.Value<int>("UiType") <= 0 || entry["ConsumeCount"] is null
|| entry.Value<int>("ConsumeCount") < 0 || entry["ConsumeId"] is null
|| entry.Value<int>("ConsumeId") < 0)
throw new InvalidDataException($"Purchase {id} requires a UI type and nonnegative price/currency.");
if (PlayerFields.Any(field => entry.Property(field) is not null)
|| (entry["PurchaseSignInInfo"] as JObject)?.Property("PurchaseSignInData") is not null)
throw new InvalidDataException($"Purchase {id} contains player state instead of catalog data.");
foreach (string field in new[] { "TimeToShelve", "TimeToUnShelve", "TimeToInvalid" })
if (entry[field] is null || entry.Value<long>(field) < 0)
throw new InvalidDataException($"Purchase {id} requires nonnegative {field} (0 means unrestricted).");
if (entry["NormalDiscounts"] is JObject discounts)
foreach (JProperty discount in discounts.Properties())
if (!int.TryParse(discount.Name, out int purchaseNumber) || purchaseNumber <= 0
|| discount.Value.Value<int>() is < 0 or > 10000)
throw new InvalidDataException($"Purchase {id} has an invalid discount tier.");
if (entry["DailyRewardDays"] is not null && entry.Value<int>("DailyRewardDays") <= 0)
throw new InvalidDataException($"Purchase {id} has an invalid daily reward duration.");
}
return catalog;
}

public List<dynamic> List(IEnumerable<int>? uiTypes, long now)
{
HashSet<int>? requested = uiTypes?.ToHashSet();
return entries.Values
.Where(entry => entry.Value<bool?>("Enabled") != false
&& (requested is null || requested.Contains(entry.Value<int>("UiType")))
&& !HasEnded(entry.Value<long>("TimeToInvalid"), now)
&& !HasEnded(entry.Value<long>("TimeToUnShelve"), now))
.Select(entry => (dynamic)ToPurchaseInfo(entry)).ToList();
}

public Dictionary<dynamic, dynamic>? Find(uint id) =>
entries.TryGetValue(id, out JObject? entry) && entry.Value<bool?>("Enabled") != false
? ToPurchaseInfo(entry) : null;

public int DailyRewardDays(uint id) => entries.TryGetValue(id, out JObject? entry)
? entry.Value<int?>("DailyRewardDays") ?? 0 : 0;

private static bool HasEnded(long end, long now) => end > 0 && end <= now;

private static Dictionary<dynamic, dynamic> ToPurchaseInfo(JObject entry)
{
// A fresh recursive copy prevents a player's state from leaking into the catalog.
Dictionary<dynamic, dynamic> info = JsonSnapshot.ReadDynamic(entry)!;
info.Remove("Enabled");
info.Remove("DailyRewardDays");
// No ownership-based price reduction is configured; client discount tiers apply separately.
info["ConvertSwitch"] = entry.Value<int>("ConsumeCount");
info["DiscountCouponInfos"] = null!;
return info;
}

public static int AvailabilityCode(Dictionary<dynamic, dynamic> info, long now)
{
long start = Convert.ToInt64((object)info["TimeToShelve"]);
long end = Convert.ToInt64((object)info["TimeToUnShelve"]);
long invalid = Convert.ToInt64((object)info["TimeToInvalid"]);
if (start > now) return 20053002;
if (HasEnded(invalid, now)) return 20053003;
return HasEnded(end, now) ? 20053004 : 0;
}

public static int UnitPrice(Dictionary<dynamic, dynamic> info, int bought)
{
int price = Convert.ToInt32((object)info["ConsumeCount"]);
int basisPoints = 10000;
int selectedTier = 0;
if (info.TryGetValue("NormalDiscounts", out dynamic? raw) && raw is Dictionary<dynamic, dynamic> discounts)
foreach (var tier in discounts)
{
int number = Convert.ToInt32((object)tier.Key);
if (number <= (long)bought + 1 && number > selectedTier)
{
selectedTier = number;
basisPoints = Convert.ToInt32((object)tier.Value);
}
}
return checked((int)((long)price * basisPoints / 10000));
}
}
15 changes: 12 additions & 3 deletions AscNet.GameServer/Handlers/AccountModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -724,16 +724,19 @@ private static List<dynamic> BuildPurchaseClientInfoLoginData(Player player)
{
Id = (uint)package.Id,
UiType = monthlyUiType,
BuyTimes = player.PurchaseBuyTimes.GetValueOrDefault((uint)package.Id),
DailyRewardRemainDay = 0,
IsDailyRewardGet = false
BuyTimes = Math.Max(player.PurchaseBuyTimes.GetValueOrDefault((uint)package.Id),
PayModule.RemainingDays(player, (uint)package.Id) > 0 ? 1 : 0),
DailyRewardRemainDay = PayModule.RemainingDays(player, (uint)package.Id),
BuyLimitRemainDay = PayModule.RemainingDays(player, (uint)package.Id),
IsDailyRewardGet = player.PurchaseDailyPasses.GetValueOrDefault((uint)package.Id)?.LastClaimDay == PayModule.PurchaseDay()
})
.ToList();
}

private static NotifyLogin BuildNotifyLogin(Session session)
{
ItemModule.ResumePendingItemUse(session);
ItemModule.ReconcileDailyAssetPurchaseCounts(session.inventory);
PayModule.ResumePendingPurchase(session);
BiancaTheatreModule.PrepareLogin(session);
GuildModule.PrepareLogin(session);
Expand Down Expand Up @@ -1340,6 +1343,7 @@ static void DoLogin(Session session, bool updateLoginAccounting)
NewPlayerTaskActiveDay = session.player.PlayerData.NewPlayerTaskActiveDay
};
NotifyPayInfo notifyPayInfo = BuildNotifyPayInfo();
PayModule.GrantMailDailyRewards(session, sendPush: false);
long mailNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
bool mailStateChanged = MailModule.EnsureSystemMails(session.player, mailNow)
| MailModule.ReconcileExpiry(session.player, mailNow);
Expand All @@ -1348,6 +1352,11 @@ static void DoLogin(Session session, bool updateLoginAccounting)
session.player.SaveChecked();
NotifyFunctionalEntranceData notifyFunctionalEntranceData = BuildFunctionalEntranceData();
PurchaseDailyNotify purchaseDailyNotify = BuildPurchaseDailyNotify();
PayModule.AddSignInNotifications(purchaseDailyNotify, session.player);
foreach (uint id in session.player.PurchaseDailyPasses.Keys)
if (PayModule.RemainingDays(session.player, id) > 0
&& session.player.PurchaseDailyPasses[id].LastClaimDay < PayModule.PurchaseDay())
purchaseDailyNotify.DailyRewardInfoList.Add(new Dictionary<string, object> { ["Id"] = id });
NotifyPurchaseRecommendConfig purchaseRecommendConfig = BuildPurchaseRecommendConfig();
// Seed the manual before NotifyLogin fires login-complete; the late full push refreshes Lotto/Purchase after cache initialization.
session.SendPush(WheelchairManualModule.BuildPayload(session, DateTimeOffset.UtcNow));
Expand Down
Loading