feat(channelserver): implement monthly guild item claim tracking

Players could never claim monthly guild items because the handler
always returned 0x01 (claimed). Now tracks per-character per-type
(standard/HLC/EXC) claim timestamps in the stamps table, comparing
against the current month boundary to determine claim eligibility.

Adds MonthStart() to gametime, extends StampRepo with
GetMonthlyClaimed/SetMonthlyClaimed, and includes schema migration
31-monthly-items.sql.
This commit is contained in:
Houmgaor
2026-02-22 16:46:57 +01:00
parent 302453ce8e
commit 2acbb5d03a
11 changed files with 187 additions and 4 deletions

View File

@@ -52,6 +52,7 @@ func TestHandleMsgMhfGenerateUdGuildMap(t *testing.T) {
func TestHandleMsgMhfCheckMonthlyItem(t *testing.T) {
server := createMockServer()
server.stampRepo = &mockStampRepoForItems{monthlyClaimedErr: errNotFound}
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{
@@ -73,6 +74,7 @@ func TestHandleMsgMhfCheckMonthlyItem(t *testing.T) {
func TestHandleMsgMhfAcquireMonthlyItem(t *testing.T) {
server := createMockServer()
server.stampRepo = &mockStampRepoForItems{}
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAcquireMonthlyItem{

View File

@@ -383,15 +383,48 @@ func handleMsgMhfSetGuildManageRight(s *Session, p mhfpacket.MHFPacket) {
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4))
}
// monthlyTypeString maps the packet's Type field to the DB column prefix.
func monthlyTypeString(t uint8) string {
switch t {
case 0:
return "monthly"
case 1:
return "monthly_hl"
case 2:
return "monthly_ex"
default:
return ""
}
}
func handleMsgMhfCheckMonthlyItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfCheckMonthlyItem)
typeStr := monthlyTypeString(pkt.Type)
if typeStr == "" {
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
return
}
claimed, err := s.server.stampRepo.GetMonthlyClaimed(s.charID, typeStr)
if err != nil || claimed.Before(TimeMonthStart()) {
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
return
}
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x01})
// TODO: Implement month-by-month tracker, 0 = Not claimed, 1 = Claimed
// Also handles HLC and EXC items, IDs = 064D, 076B
}
func handleMsgMhfAcquireMonthlyItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireMonthlyItem)
typeStr := monthlyTypeString(pkt.Unk0)
if typeStr != "" {
if err := s.server.stampRepo.SetMonthlyClaimed(s.charID, typeStr, TimeAdjusted()); err != nil {
s.logger.Error("Failed to set monthly item claimed", zap.Error(err))
}
}
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
}

View File

@@ -6,6 +6,7 @@ import (
"time"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
)
// TestGuildCreation tests basic guild creation
@@ -822,3 +823,104 @@ func TestGuildAllianceRelationship(t *testing.T) {
})
}
}
// --- handleMsgMhfCheckMonthlyItem tests ---
func TestCheckMonthlyItem_NotClaimed(t *testing.T) {
server := createMockServer()
stampMock := &mockStampRepoForItems{
monthlyClaimedErr: errNotFound,
}
server.stampRepo = stampMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{AckHandle: 100, Type: 0}
handleMsgMhfCheckMonthlyItem(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatalf("Response too short: %d bytes", len(p.data))
}
default:
t.Error("No response packet queued")
}
}
func TestCheckMonthlyItem_ClaimedThisMonth(t *testing.T) {
server := createMockServer()
stampMock := &mockStampRepoForItems{
monthlyClaimed: TimeAdjusted(), // claimed right now (within this month)
}
server.stampRepo = stampMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{AckHandle: 100, Type: 0}
handleMsgMhfCheckMonthlyItem(session, pkt)
select {
case <-session.sendPackets:
// Response received — claimed this month should return 1
default:
t.Error("No response packet queued")
}
}
func TestCheckMonthlyItem_ClaimedLastMonth(t *testing.T) {
server := createMockServer()
stampMock := &mockStampRepoForItems{
monthlyClaimed: TimeMonthStart().Add(-24 * time.Hour), // before this month
}
server.stampRepo = stampMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{AckHandle: 100, Type: 1}
handleMsgMhfCheckMonthlyItem(session, pkt)
select {
case <-session.sendPackets:
// Response received — last month claim should return 0 (unclaimed)
default:
t.Error("No response packet queued")
}
}
func TestCheckMonthlyItem_UnknownType(t *testing.T) {
server := createMockServer()
stampMock := &mockStampRepoForItems{}
server.stampRepo = stampMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{AckHandle: 100, Type: 99}
handleMsgMhfCheckMonthlyItem(session, pkt)
select {
case <-session.sendPackets:
// Unknown type returns 0 (unclaimed) without DB call
default:
t.Error("No response packet queued")
}
}
func TestAcquireMonthlyItem_MarksAsClaimed(t *testing.T) {
server := createMockServer()
stampMock := &mockStampRepoForItems{}
server.stampRepo = stampMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAcquireMonthlyItem{AckHandle: 100, Unk0: 2}
handleMsgMhfAcquireMonthlyItem(session, pkt)
if !stampMock.monthlySetCalled {
t.Error("SetMonthlyClaimed should be called")
}
if stampMock.monthlySetType != "monthly_ex" {
t.Errorf("SetMonthlyClaimed type = %q, want %q", stampMock.monthlySetType, "monthly_ex")
}
select {
case <-session.sendPackets:
default:
t.Error("No response packet queued")
}
}

View File

@@ -245,6 +245,8 @@ type StampRepo interface {
GetTotals(charID uint32, stampType string) (total, redeemed uint16, err error)
ExchangeYearly(charID uint32) (total, redeemed uint16, err error)
Exchange(charID uint32, stampType string) (total, redeemed uint16, err error)
GetMonthlyClaimed(charID uint32, monthlyType string) (time.Time, error)
SetMonthlyClaimed(charID uint32, monthlyType string, now time.Time) error
}
// DistributionRepo defines the contract for distribution/event item data access.

View File

@@ -663,6 +663,12 @@ type mockStampRepoForItems struct {
exchangeErr error
yearlyResult [2]uint16
yearlyErr error
// Monthly item fields
monthlyClaimed time.Time
monthlyClaimedErr error
monthlySetCalled bool
monthlySetType string
}
func (m *mockStampRepoForItems) GetChecked(_ uint32, _ string) (time.Time, error) {
@@ -696,6 +702,16 @@ func (m *mockStampRepoForItems) Exchange(_ uint32, _ string) (uint16, uint16, er
return m.exchangeResult[0], m.exchangeResult[1], m.exchangeErr
}
func (m *mockStampRepoForItems) GetMonthlyClaimed(_ uint32, _ string) (time.Time, error) {
return m.monthlyClaimed, m.monthlyClaimedErr
}
func (m *mockStampRepoForItems) SetMonthlyClaimed(_ uint32, monthlyType string, _ time.Time) error {
m.monthlySetCalled = true
m.monthlySetType = monthlyType
return nil
}
// --- mockHouseRepoForItems ---
type mockHouseRepoForItems struct {

View File

@@ -59,3 +59,20 @@ func (r *StampRepository) Exchange(charID uint32, stampType string) (total, rede
err = r.db.QueryRow(fmt.Sprintf("UPDATE stamps SET %s_redeemed=%s_redeemed+8 WHERE character_id=$1 RETURNING %s_total, %s_redeemed", stampType, stampType, stampType, stampType), charID).Scan(&total, &redeemed)
return
}
// GetMonthlyClaimed returns the last monthly item claim time for the given type.
func (r *StampRepository) GetMonthlyClaimed(charID uint32, monthlyType string) (time.Time, error) {
var claimed time.Time
err := r.db.QueryRow(
fmt.Sprintf("SELECT %s_claimed FROM stamps WHERE character_id=$1", monthlyType), charID,
).Scan(&claimed)
return claimed, err
}
// SetMonthlyClaimed updates the monthly item claim time for the given type.
func (r *StampRepository) SetMonthlyClaimed(charID uint32, monthlyType string, now time.Time) error {
_, err := r.db.Exec(
fmt.Sprintf("UPDATE stamps SET %s_claimed=$1 WHERE character_id=$2", monthlyType), now, charID,
)
return err
}

View File

@@ -14,4 +14,5 @@ func TimeAdjusted() time.Time { return gametime.Adjusted() }
func TimeMidnight() time.Time { return gametime.Midnight() }
func TimeWeekStart() time.Time { return gametime.WeekStart() }
func TimeWeekNext() time.Time { return gametime.WeekNext() }
func TimeMonthStart() time.Time { return gametime.MonthStart() }
func TimeGameAbsolute() uint32 { return gametime.GameAbsolute() }