From 2acbb5d03a653788994d188c52ea7cd4bb334f6b Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sun, 22 Feb 2026 16:46:57 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + common/gametime/gametime.go | 6 ++ docs/technical-debt.md | 4 +- schemas/patch-schema/31-monthly-items.sql | 3 + .../channelserver/handlers_coverage2_test.go | 2 + server/channelserver/handlers_guild.go | 37 ++++++- server/channelserver/handlers_guild_test.go | 102 ++++++++++++++++++ server/channelserver/repo_interfaces.go | 2 + server/channelserver/repo_mocks_test.go | 16 +++ server/channelserver/repo_stamp.go | 17 +++ server/channelserver/sys_time.go | 1 + 11 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 schemas/patch-schema/31-monthly-items.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5607b36..81c08e8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/common/gametime/gametime.go b/common/gametime/gametime.go index 1612a0831..c76f4f80c 100644 --- a/common/gametime/gametime.go +++ b/common/gametime/gametime.go @@ -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 { diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 83e9bb592..2b53f146f 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -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** diff --git a/schemas/patch-schema/31-monthly-items.sql b/schemas/patch-schema/31-monthly-items.sql new file mode 100644 index 000000000..6c78e6ab2 --- /dev/null +++ b/schemas/patch-schema/31-monthly-items.sql @@ -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; diff --git a/server/channelserver/handlers_coverage2_test.go b/server/channelserver/handlers_coverage2_test.go index 73816b8cb..7ef6fc560 100644 --- a/server/channelserver/handlers_coverage2_test.go +++ b/server/channelserver/handlers_coverage2_test.go @@ -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{ diff --git a/server/channelserver/handlers_guild.go b/server/channelserver/handlers_guild.go index d066f9c23..97ef27c9f 100644 --- a/server/channelserver/handlers_guild.go +++ b/server/channelserver/handlers_guild.go @@ -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)) } diff --git a/server/channelserver/handlers_guild_test.go b/server/channelserver/handlers_guild_test.go index 4487ce5e5..5e5556458 100644 --- a/server/channelserver/handlers_guild_test.go +++ b/server/channelserver/handlers_guild_test.go @@ -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") + } +} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 4dfdee0dc..ba7cf6cf5 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index df49fc6d8..302b8515a 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 { diff --git a/server/channelserver/repo_stamp.go b/server/channelserver/repo_stamp.go index 28c65de0e..26a9f4d64 100644 --- a/server/channelserver/repo_stamp.go +++ b/server/channelserver/repo_stamp.go @@ -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 +} diff --git a/server/channelserver/sys_time.go b/server/channelserver/sys_time.go index 885bde66c..1520db47a 100644 --- a/server/channelserver/sys_time.go +++ b/server/channelserver/sys_time.go @@ -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() }