diff --git a/SVSim.Database/Models/ViewerAcquireHistoryEntry.cs b/SVSim.Database/Models/ViewerAcquireHistoryEntry.cs
new file mode 100644
index 0000000..7837f68
--- /dev/null
+++ b/SVSim.Database/Models/ViewerAcquireHistoryEntry.cs
@@ -0,0 +1,30 @@
+namespace SVSim.Database.Models;
+
+///
+/// One row per grant emitted by InventoryTransaction.CommitAsync. Rendered as the
+/// histories[] array on POST /item_acquire_history/info. Capped at 300 rows
+/// per viewer; oldest pruned on commit.
+///
+public sealed class ViewerAcquireHistoryEntry
+{
+ public long Id { get; set; }
+ public long ViewerId { get; set; }
+
+ /// UserGoodsType cast to int; matches the wire reward_type.
+ public int RewardType { get; set; }
+
+ /// Detail id for the goods; 0 for wallet currencies.
+ public long RewardDetailId { get; set; }
+
+ /// Delta granted in this row — NOT a post-state total.
+ public int RewardCount { get; set; }
+
+ /// GrantSource cast to int; matches the wire acquire_type.
+ public int AcquireType { get; set; }
+
+ /// Pre-localized text the client renders verbatim. Capped at 64 chars.
+ public string Message { get; set; } = string.Empty;
+
+ /// Server UTC at commit time. Stamped once per CommitAsync, identical across all rows in that commit.
+ public DateTime AcquireTime { get; set; }
+}
diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs
index 5fd2e3f..4297621 100644
--- a/SVSim.Database/SVSimDbContext.cs
+++ b/SVSim.Database/SVSimDbContext.cs
@@ -103,6 +103,7 @@ public class SVSimDbContext : DbContext
public DbSet ViewerPresents => Set();
public DbSet TutorialPresentEntries => Set();
+ public DbSet ViewerAcquireHistory => Set();
public DbSet ArenaTwoPickRewards { get; set; } = null!;
public DbSet ViewerArenaTwoPickRuns { get; set; } = null!;
@@ -398,6 +399,19 @@ public class SVSimDbContext : DbContext
b.Property(p => p.PresentId).HasMaxLength(64);
});
+ modelBuilder.Entity(b =>
+ {
+ b.HasKey(e => e.Id);
+ b.Property(e => e.Id).ValueGeneratedOnAdd();
+ b.Property(e => e.Message).HasMaxLength(64).IsRequired();
+ b.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.ViewerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id })
+ .HasDatabaseName("IX_ViewerAcquireHistory_ViewerId_AcquireTime_Id");
+ });
+
base.OnModelCreating(modelBuilder);
}
diff --git a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs
index 8e98bbf..7de50e4 100644
--- a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs
+++ b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs
@@ -1,3 +1,5 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using SVSim.Database.Services.Inventory;
namespace SVSim.UnitTests.Services.Inventory;
@@ -37,4 +39,31 @@ public class InventoryHistoryTests
var cfg = new InventoryLoadConfig { Source = GrantSource.PackOpen };
Assert.That(cfg.Source, Is.EqualTo(GrantSource.PackOpen));
}
+
+ [Test]
+ public async Task ViewerAcquireHistory_DbSet_round_trips_a_row()
+ {
+ using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ using var scope = factory.Services.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+
+ ctx.ViewerAcquireHistory.Add(new SVSim.Database.Models.ViewerAcquireHistoryEntry
+ {
+ ViewerId = viewerId,
+ RewardType = (int)SVSim.Database.Enums.UserGoodsType.Rupy,
+ RewardDetailId = 0,
+ RewardCount = 50,
+ AcquireType = (int)GrantSource.DailyBonus,
+ Message = "Daily Bonus",
+ AcquireTime = new DateTime(2026, 6, 9, 12, 0, 0, DateTimeKind.Utc),
+ });
+ await ctx.SaveChangesAsync();
+
+ var roundtrip = await ctx.ViewerAcquireHistory.AsNoTracking()
+ .Where(h => h.ViewerId == viewerId).ToListAsync();
+ Assert.That(roundtrip, Has.Count.EqualTo(1));
+ Assert.That(roundtrip[0].RewardCount, Is.EqualTo(50));
+ Assert.That(roundtrip[0].AcquireType, Is.EqualTo((int)GrantSource.DailyBonus));
+ }
}