mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-24 16:43:37 +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
|
### 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"}`)
|
- 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
|
- Rework object ID allocation: per-session IDs replace shared map, simplify stage entry notifications
|
||||||
- Better config file handling and structure
|
- Better config file handling and structure
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ func WeekNext() time.Time {
|
|||||||
return WeekStart().Add(time.Hour * 24 * 7)
|
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)
|
// GameAbsolute returns the current position within the 5760-second (96-minute)
|
||||||
// in-game day/night cycle, offset by 2160 seconds.
|
// in-game day/night cycle, offset by 2160 seconds.
|
||||||
func GameAbsolute() uint32 {
|
func GameAbsolute() uint32 {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ These TODOs represent features that are visibly broken for players.
|
|||||||
| Location | Issue | Impact |
|
| 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 |
|
| `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_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_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 |
|
| `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
|
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
|
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
|
4. **Split `repo_guild.go`** — last oversized file after the recent refactoring push
|
||||||
5. ~~**Fix `fmt.Sprintf` in logger calls**~~ — **Done**
|
5. ~~**Fix `fmt.Sprintf` in logger calls**~~ — **Done**
|
||||||
6. ~~**Add `LoopDelay` Viper default**~~ — **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) {
|
func TestHandleMsgMhfCheckMonthlyItem(t *testing.T) {
|
||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
|
server.stampRepo = &mockStampRepoForItems{monthlyClaimedErr: errNotFound}
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{
|
pkt := &mhfpacket.MsgMhfCheckMonthlyItem{
|
||||||
@@ -73,6 +74,7 @@ func TestHandleMsgMhfCheckMonthlyItem(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleMsgMhfAcquireMonthlyItem(t *testing.T) {
|
func TestHandleMsgMhfAcquireMonthlyItem(t *testing.T) {
|
||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
|
server.stampRepo = &mockStampRepoForItems{}
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
pkt := &mhfpacket.MsgMhfAcquireMonthlyItem{
|
pkt := &mhfpacket.MsgMhfAcquireMonthlyItem{
|
||||||
|
|||||||
@@ -383,15 +383,48 @@ func handleMsgMhfSetGuildManageRight(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4))
|
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) {
|
func handleMsgMhfCheckMonthlyItem(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfCheckMonthlyItem)
|
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})
|
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) {
|
func handleMsgMhfAcquireMonthlyItem(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfAcquireMonthlyItem)
|
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))
|
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
cfg "erupe-ce/config"
|
cfg "erupe-ce/config"
|
||||||
|
"erupe-ce/network/mhfpacket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGuildCreation tests basic guild creation
|
// 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)
|
GetTotals(charID uint32, stampType string) (total, redeemed uint16, err error)
|
||||||
ExchangeYearly(charID uint32) (total, redeemed uint16, err error)
|
ExchangeYearly(charID uint32) (total, redeemed uint16, err error)
|
||||||
Exchange(charID uint32, stampType string) (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.
|
// DistributionRepo defines the contract for distribution/event item data access.
|
||||||
|
|||||||
@@ -663,6 +663,12 @@ type mockStampRepoForItems struct {
|
|||||||
exchangeErr error
|
exchangeErr error
|
||||||
yearlyResult [2]uint16
|
yearlyResult [2]uint16
|
||||||
yearlyErr error
|
yearlyErr error
|
||||||
|
|
||||||
|
// Monthly item fields
|
||||||
|
monthlyClaimed time.Time
|
||||||
|
monthlyClaimedErr error
|
||||||
|
monthlySetCalled bool
|
||||||
|
monthlySetType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStampRepoForItems) GetChecked(_ uint32, _ string) (time.Time, error) {
|
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
|
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 ---
|
// --- mockHouseRepoForItems ---
|
||||||
|
|
||||||
type mockHouseRepoForItems struct {
|
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)
|
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
|
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 TimeMidnight() time.Time { return gametime.Midnight() }
|
||||||
func TimeWeekStart() time.Time { return gametime.WeekStart() }
|
func TimeWeekStart() time.Time { return gametime.WeekStart() }
|
||||||
func TimeWeekNext() time.Time { return gametime.WeekNext() }
|
func TimeWeekNext() time.Time { return gametime.WeekNext() }
|
||||||
|
func TimeMonthStart() time.Time { return gametime.MonthStart() }
|
||||||
func TimeGameAbsolute() uint32 { return gametime.GameAbsolute() }
|
func TimeGameAbsolute() uint32 { return gametime.GameAbsolute() }
|
||||||
|
|||||||
Reference in New Issue
Block a user