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

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Monthly guild item claim tracking per character per type (standard/HLC/EXC), with schema migration (`31-monthly-items.sql`) adding claim timestamps to the `stamps` table
- API: `GET /version` endpoint returning server name and client mode (`{"clientMode":"ZZ","name":"Erupe-CE"}`)
- Rework object ID allocation: per-session IDs replace shared map, simplify stage entry notifications
- Better config file handling and structure

View File

@@ -31,6 +31,12 @@ func WeekNext() time.Time {
return WeekStart().Add(time.Hour * 24 * 7)
}
// MonthStart returns the first day of the current month at midnight in JST.
func MonthStart() time.Time {
midnight := Midnight()
return time.Date(midnight.Year(), midnight.Month(), 1, 0, 0, 0, 0, midnight.Location())
}
// GameAbsolute returns the current position within the 5760-second (96-minute)
// in-game day/night cycle, offset by 2160 seconds.
func GameAbsolute() uint32 {

View File

@@ -31,7 +31,7 @@ These TODOs represent features that are visibly broken for players.
| Location | Issue | Impact |
|----------|-------|--------|
| `model_character.go:88,101,113` | `TODO: fix bookshelf data pointer` for G10-ZZ, F4-F5, and S6 versions | Wrong pointer corrupts character save reads for three game versions |
| `handlers_guild.go:389` | `TODO: Implement month-by-month tracker` — always returns `0x01` (claimed) | Players can never claim monthly guild items |
| ~~`handlers_guild.go:389`~~ | ~~`TODO: Implement month-by-month tracker` — always returns `0x01` (claimed)~~ | ~~Players can never claim monthly guild items~~ **Fixed.** Now tracks per-character per-type monthly claims via `stamps` table. |
| `handlers_guild_ops.go:148` | `TODO: Move this value onto rp_yesterday and reset to 0... daily?` | Guild daily RP rollover logic is missing entirely |
| `handlers_achievement.go:125` | `TODO: Notify on rank increase` — always returns `false` | Achievement rank-up notifications are silently suppressed |
| `handlers_guild_info.go:443` | `TODO: Enable GuildAlliance applications` — hardcoded `true` | Guild alliance applications are always open regardless of setting |
@@ -157,7 +157,7 @@ Based on impact and the momentum from recent repo-interface refactoring:
1. **Add tests for `handlers_session.go` and `handlers_gacha.go`** — highest-risk untested code on the critical login and economy paths
2. **Refactor signserver to use repository interfaces** — completes the pattern established in channelserver and surfaces 8 hidden error paths
3. **Fix monthly guild item claim** (`handlers_guild.go:389`) — small fix with direct gameplay impact
3. ~~**Fix monthly guild item claim**~~ (`handlers_guild.go:389`) — **Done**
4. **Split `repo_guild.go`** — last oversized file after the recent refactoring push
5. ~~**Fix `fmt.Sprintf` in logger calls**~~**Done**
6. ~~**Add `LoopDelay` Viper default**~~**Done**

View File

@@ -0,0 +1,3 @@
ALTER TABLE IF EXISTS public.stamps ADD COLUMN IF NOT EXISTS monthly_claimed TIMESTAMP WITH TIME ZONE;
ALTER TABLE IF EXISTS public.stamps ADD COLUMN IF NOT EXISTS monthly_hl_claimed TIMESTAMP WITH TIME ZONE;
ALTER TABLE IF EXISTS public.stamps ADD COLUMN IF NOT EXISTS monthly_ex_claimed TIMESTAMP WITH TIME ZONE;

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() }