mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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**
|
||||
|
||||
3
schemas/patch-schema/31-monthly-items.sql
Normal file
3
schemas/patch-schema/31-monthly-items.sql
Normal 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;
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user