fix(pack): daily half-off single spends crystals and gates re-purchase
The DAILY child gacha (type_detail=3, is_daily_single=true) is the once-per-day half-off crystal single-pack. Two bugs: 1. /pack/open Daily case was calling TrySpendAsync(Rupee) — decompiled client (GachaUI.cs:1046 CheckBuyPackWithCrystal, :1187 SetClystalConfirmDialog) confirms it's a crystal purchase. Freeplay mode masked the mismatch in testing. Swapped to Crystal + insufficient_crystals error. 2. /pack/info kept emitting is_daily_single=true after the viewer's first claim, so the client re-showed the half-off button and a second click 400'd with daily_free_already_claimed. Added a per-parent-pack gate that flips is_daily_single false when LastDailyFreeAt.Date == today. The child entry itself stays so the CRYSTAL_MULTI full-price button still activates. TODO(daily-reset) comments now reference docs/plans/daily-reset-service.md (in the outer repo) — the current UTC-midnight comparison will migrate to the JST 02:00 reset boundary via that plan. Updated the existing daily test to use cost=50 and assert Crystals: 200 -> 150 + Rupees untouched (previously used cost=0 so the currency choice was undetectable). Added two /pack/info gate tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -299,4 +299,87 @@ public class PackControllerInfoTests
|
||||
Assert.That(children.Count, Is.EqualTo(1), "Only the ticket child should remain after today's free claim");
|
||||
Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_emits_is_daily_single_true_before_todays_claim()
|
||||
{
|
||||
// Client's DAILY-branch UI (GachaPackAreaLayout.cs:420) renders the half-off button
|
||||
// iff the wire ships `is_daily_single: true`. Pre-claim it must be truthy.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas =
|
||||
{
|
||||
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
|
||||
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var daily = doc.RootElement.GetProperty("pack_config_list")[0]
|
||||
.GetProperty("child_gacha_info").EnumerateArray()
|
||||
.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
|
||||
Assert.That(daily.TryGetProperty("is_daily_single", out var flag), Is.True,
|
||||
"is_daily_single must be present when claimable");
|
||||
Assert.That(flag.GetBoolean(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_suppresses_is_daily_single_after_todays_claim()
|
||||
{
|
||||
// Bug repro: after a successful DAILY open, /pack/info kept emitting is_daily_single=true,
|
||||
// so the client re-showed the half-off button. Clicking it again 400'd with
|
||||
// daily_free_already_claimed. Post-claim the flag must be false (or omitted) so the
|
||||
// client's DAILY-branch renders the full-price crystal button instead. The child entry
|
||||
// itself stays so CRYSTAL_MULTI still activates.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas =
|
||||
{
|
||||
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
|
||||
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
|
||||
v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 1, LastDailyFreeAt = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var children = doc.RootElement.GetProperty("pack_config_list")[0]
|
||||
.GetProperty("child_gacha_info").EnumerateArray().ToList();
|
||||
Assert.That(children.Count, Is.EqualTo(2), "child entries stay; only the daily-single flag flips");
|
||||
|
||||
var daily = children.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
|
||||
// With WhenWritingDefault, the wire omits the key entirely when false — either shape is
|
||||
// fine (PackInfoTask.cs:143-150 defaults to false on missing key).
|
||||
bool value = daily.TryGetProperty("is_daily_single", out var flag) && flag.GetBoolean();
|
||||
Assert.That(value, Is.False,
|
||||
"is_daily_single must be false/absent post-claim so the client stops offering the half-off button");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,8 +399,10 @@ public class PackControllerOpenTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt()
|
||||
public async Task Open_daily_debits_crystals_and_rejects_second_attempt()
|
||||
{
|
||||
// DAILY is the once-per-day half-off CRYSTAL single-pack (GachaUI.cs:1046 →
|
||||
// CheckBuyPackWithCrystal). Cost=50 crystals; second same-day attempt 400s.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
@@ -412,8 +414,11 @@ public class PackControllerOpenTests
|
||||
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 0, CardCount = 1, IsDailySingle = true } },
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 1, IsDailySingle = true } },
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 200;
|
||||
v.Currency.Rupees = 200;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -424,9 +429,17 @@ public class PackControllerOpenTests
|
||||
var first = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync());
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Crystals, Is.EqualTo(150UL), "200 starting - 50 daily-single crystal cost");
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(200UL), "Rupees must not be touched by a DAILY (crystal) open");
|
||||
}
|
||||
|
||||
var second = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||
"Second daily-free open the same UTC day should be rejected.");
|
||||
"Second daily-single open the same UTC day should be rejected.");
|
||||
}
|
||||
|
||||
// ---------------- Regression tests for wire-shape quirks ----------------
|
||||
|
||||
Reference in New Issue
Block a user