feat(campaign): implement Event Tent campaign system

Adds the full campaign/event-tent feature:

Packet layer:
- MsgMhfApplyCampaign: Unk0/Unk1/Unk2 → CampaignID/Code (null-terminated 16-byte string)
- MsgMhfAcquireItem: Unk0/Length/Unk1 → RewardIDs []uint32
- MsgMhfEnumerateItem: remove Unk0/Unk1 (RE'd: zeroed + always-2, ignored)
- MsgMhfStateCampaign: Unk1 → NullPadding (RE'd: always zero)
- MsgMhfTransferItem: Unk0/Unk1/Unk2 → QuestID/ItemType/Quantity (RE'd)

Handler layer (handlers_campaign.go):
- handleMsgMhfEnumerateCampaign: reads campaigns, categories, links from DB;
  prefix moved into pascal string slot 3 of each event entry (RE confirmed
  3-section response format — removes spurious intermediate section)
- handleMsgMhfStateCampaign: returns stamp count and redeemable flag
- handleMsgMhfApplyCampaign: validates and records code redemption
- handleMsgMhfEnumerateItem: lists rewards gated by stamp count
- handleMsgMhfAcquireItem: marks rewards as claimed
- handleMsgMhfTransferItem: records campaign quest completion (item_type=9)

Quest gating (handlers_quest.go):
- makeEventQuest: for QuestTypeSpecialTool, check campaign stamp count and
  deadline before allowing the quest (WriteBool true/false)

Database:
- 0010_campaign.sql: 8-table schema (campaigns, categories, links, rewards,
  claimed, state, codes, quest)
- CampaignDemo.sql: community-researched live game campaign data
This commit is contained in:
Houmgaor
2026-03-20 11:22:25 +01:00
parent 97ef09be64
commit 77e7969579
10 changed files with 5528 additions and 120 deletions

View File

@@ -9,55 +9,83 @@ import (
"time"
)
// CampaignEvent represents a promotional campaign event.
type CampaignEvent struct {
ID uint32
Unk0 uint32
MinHR int16
MaxHR int16
MinSR int16
MaxSR int16
MinGR int16
MaxGR int16
Unk1 uint16
Unk2 uint8
Unk3 uint8
Unk4 uint16
Unk5 uint16
Start time.Time
End time.Time
Unk6 uint8
String0 string
String1 string
String2 string
String3 string
Link string
Prefix string
Categories []uint16
ID uint32 `db:"id"`
MinHR int16 `db:"min_hr"`
MaxHR int16 `db:"max_hr"`
MinSR int16 `db:"min_sr"`
MaxSR int16 `db:"max_sr"`
MinGR int16 `db:"min_gr"`
MaxGR int16 `db:"max_gr"`
RewardType uint16 `db:"reward_type"`
Stamps uint8 `db:"stamps"`
ReceiveType uint8 `db:"receive_type"`
BackgroundID uint16 `db:"background_id"`
Start time.Time `db:"start_time"`
End time.Time `db:"end_time"`
Title string `db:"title"`
Reward string `db:"reward"`
Link string `db:"link"`
Prefix string `db:"code_prefix"`
}
// CampaignCategory represents a category grouping for campaign events.
type CampaignCategory struct {
ID uint16
Type uint8
Title string
Description string
ID uint16 `db:"id"`
Type uint8 `db:"type"`
Title string `db:"title"`
Description string `db:"description"`
}
// CampaignLink links a campaign event to its items/rewards.
type CampaignLink struct {
CategoryID uint16
CampaignID uint32
CategoryID uint16 `db:"category_id"`
CampaignID uint32 `db:"campaign_id"`
}
type CampaignReward struct {
ID uint32 `db:"id"`
ItemType uint16 `db:"item_type"`
Quantity uint16 `db:"quantity"`
ItemID uint16 `db:"item_id"`
Deadline time.Time `db:"deadline"`
}
// campaignRequiredStamps returns the stamp requirement for a campaign,
// clamping to a minimum of 1. Campaigns with 0 stamps in the DB are
// treated as requiring a single stamp (code redemption) to unlock.
func campaignRequiredStamps(stamps int) int {
if stamps < 1 {
return 1
}
return stamps
}
func handleMsgMhfEnumerateCampaign(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumerateCampaign)
if s.server.db == nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
events := []CampaignEvent{}
categories := []CampaignCategory{}
var events []CampaignEvent
var categories []CampaignCategory
var campaignLinks []CampaignLink
err := s.server.db.Select(&events, "SELECT id,min_hr,max_hr,min_sr,max_sr,min_gr,max_gr,reward_type,stamps,receive_type,background_id,start_time,end_time,title,reward,link,code_prefix FROM campaigns")
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
err = s.server.db.Select(&categories, "SELECT id, type, title, description FROM campaign_categories")
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
err = s.server.db.Select(&campaignLinks, "SELECT campaign_id, category_id FROM campaign_category_links")
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
if len(events) > 255 {
bf.WriteUint8(255)
bf.WriteUint16(uint16(len(events)))
@@ -66,7 +94,7 @@ func handleMsgMhfEnumerateCampaign(s *Session, p mhfpacket.MHFPacket) {
}
for _, event := range events {
bf.WriteUint32(event.ID)
bf.WriteUint32(event.Unk0)
bf.WriteUint32(0)
bf.WriteInt16(event.MinHR)
bf.WriteInt16(event.MaxHR)
bf.WriteInt16(event.MinSR)
@@ -75,34 +103,19 @@ func handleMsgMhfEnumerateCampaign(s *Session, p mhfpacket.MHFPacket) {
bf.WriteInt16(event.MinGR)
bf.WriteInt16(event.MaxGR)
}
bf.WriteUint16(event.Unk1)
bf.WriteUint8(event.Unk2)
bf.WriteUint8(event.Unk3)
bf.WriteUint16(event.Unk4)
bf.WriteUint16(event.Unk5)
bf.WriteUint16(event.RewardType)
bf.WriteUint8(event.Stamps)
bf.WriteUint8(event.ReceiveType)
bf.WriteUint16(event.BackgroundID)
bf.WriteUint16(0)
bf.WriteUint32(uint32(event.Start.Unix()))
bf.WriteUint32(uint32(event.End.Unix()))
bf.WriteUint8(event.Unk6)
ps.Uint8(bf, event.String0, true)
ps.Uint8(bf, event.String1, true)
ps.Uint8(bf, event.String2, true)
ps.Uint8(bf, event.String3, true)
bf.WriteBool(event.End.Before(time.Now()))
ps.Uint8(bf, event.Title, true)
ps.Uint8(bf, event.Reward, true)
ps.Uint8(bf, event.Prefix, true)
ps.Uint8(bf, "", false)
ps.Uint8(bf, event.Link, true)
for i := range event.Categories {
campaignLinks = append(campaignLinks, CampaignLink{event.Categories[i], event.ID})
}
}
if len(events) > 255 {
bf.WriteUint8(255)
bf.WriteUint16(uint16(len(events)))
} else {
bf.WriteUint8(uint8(len(events)))
}
for _, event := range events {
bf.WriteUint32(event.ID)
bf.WriteUint8(1) // Always 1?
bf.WriteBytes([]byte(event.Prefix))
}
if len(categories) > 255 {
@@ -137,43 +150,185 @@ func handleMsgMhfEnumerateCampaign(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfStateCampaign(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfStateCampaign)
if s.server.db == nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
bf.WriteUint16(1)
bf.WriteUint16(0)
var required int
var deadline time.Time
var stamps []uint32
err := s.server.db.Select(&stamps, "SELECT id FROM campaign_state WHERE campaign_id = $1 AND character_id = $2", pkt.CampaignID, s.charID)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
err = s.server.db.QueryRow(`SELECT stamps, end_time FROM campaigns WHERE id = $1`, pkt.CampaignID).Scan(&required, &deadline)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf.WriteUint16(uint16(len(stamps)))
required = campaignRequiredStamps(required)
if len(stamps) >= required && deadline.After(time.Now()) {
bf.WriteUint16(2)
} else {
bf.WriteUint16(0)
}
for _, v := range stamps {
bf.WriteUint32(v)
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfApplyCampaign(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfApplyCampaign)
bf := byteframe.NewByteFrame()
bf.WriteUint32(1)
doAckSimpleSucceed(s, pkt.AckHandle, bf.Data())
if s.server.db == nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
// Check if the code exists, belongs to this campaign, and check if it's a multi-code
var multi bool
err := s.server.db.QueryRow(`SELECT multi FROM public.campaign_codes WHERE code = $1 AND campaign_id = $2`, pkt.Code, pkt.CampaignID).Scan(&multi)
if err != nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
// Check if the code is already used
var exists bool
if multi {
err = s.server.db.QueryRow(`SELECT COUNT(*) > 0 FROM public.campaign_state WHERE code = $1 AND character_id = $2`, pkt.Code, s.charID).Scan(&exists)
} else {
err = s.server.db.QueryRow(`SELECT COUNT(*) > 0 FROM public.campaign_state WHERE code = $1`, pkt.Code).Scan(&exists)
}
if err != nil || exists {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
_, err = s.server.db.Exec(`INSERT INTO public.campaign_state (code, campaign_id, character_id) VALUES ($1, $2, $3)`, pkt.Code, pkt.CampaignID, s.charID)
if err != nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
}
func handleMsgMhfEnumerateItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumerateItem)
items := []struct {
Unk0 uint32
Unk1 uint16
Unk2 uint16
Unk3 uint16
Unk4 uint32
Unk5 uint32
}{}
bf := byteframe.NewByteFrame()
bf.WriteUint16(uint16(len(items)))
for _, item := range items {
bf.WriteUint32(item.Unk0)
bf.WriteUint16(item.Unk1)
bf.WriteUint16(item.Unk2)
bf.WriteUint16(item.Unk3)
bf.WriteUint32(item.Unk4)
bf.WriteUint32(item.Unk5)
if s.server.db == nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
var stamps, required, rewardType uint16
err := s.server.db.QueryRow(`SELECT COUNT(*) FROM campaign_state WHERE campaign_id = $1 AND character_id = $2`, pkt.CampaignID, s.charID).Scan(&stamps)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
err = s.server.db.QueryRow(`SELECT stamps, reward_type FROM campaigns WHERE id = $1`, pkt.CampaignID).Scan(&required, &rewardType)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
required = uint16(campaignRequiredStamps(int(required)))
if stamps >= required {
var items []CampaignReward
if rewardType == 2 {
var exists int
err = s.server.db.QueryRow(`SELECT COUNT(*) FROM campaign_quest WHERE campaign_id = $1 AND character_id = $2`, pkt.CampaignID, s.charID).Scan(&exists)
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
if exists > 0 {
err = s.server.db.Select(&items, `
SELECT id, item_type, quantity, item_id, TO_TIMESTAMP(0) AS deadline FROM campaign_rewards
WHERE campaign_id = $1 AND item_type != 9
AND NOT EXISTS (SELECT 1 FROM campaign_rewards_claimed WHERE reward_id = campaign_rewards.id AND character_id = $2)
`, pkt.CampaignID, s.charID)
} else {
err = s.server.db.Select(&items, `
SELECT cr.id, cr.item_type, cr.quantity, cr.item_id, COALESCE(c.end_time, TO_TIMESTAMP(0)) AS deadline FROM campaign_rewards cr
JOIN campaigns c ON cr.campaign_id = c.id
WHERE campaign_id = $1 AND item_type = 9`, pkt.CampaignID)
}
} else {
err = s.server.db.Select(&items, `
SELECT id, item_type, quantity, item_id, TO_TIMESTAMP(0) AS deadline FROM campaign_rewards
WHERE campaign_id = $1
AND NOT EXISTS (SELECT 1 FROM campaign_rewards_claimed WHERE reward_id = campaign_rewards.id AND character_id = $2)
`, pkt.CampaignID, s.charID)
}
if err != nil {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf.WriteUint16(uint16(len(items)))
for _, item := range items {
bf.WriteUint32(item.ID)
bf.WriteUint16(item.ItemType)
bf.WriteUint16(item.Quantity)
bf.WriteUint16(item.ItemID) //HACK:placed quest id in this field to fit with Item No pattern. however it could be another field... possibly the other unks.
bf.WriteUint16(0) //Unk4, gets cast to uint8
bf.WriteUint32(0) //Unk5
bf.WriteUint32(uint32(item.Deadline.Unix()))
}
if len(items) == 0 {
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4))
} else {
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
} else {
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4))
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfAcquireItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireItem)
if s.server.db == nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
for _, id := range pkt.RewardIDs {
_, err := s.server.db.Exec(`INSERT INTO campaign_rewards_claimed (reward_id, character_id) VALUES ($1, $2)`, id, s.charID)
if err != nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
}
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
}
func handleMsgMhfTransferItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfTransferItem)
if s.server.db == nil {
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return
}
if pkt.ItemType == 9 {
var campaignID uint32
err := s.server.db.QueryRow(`
SELECT ce.campaign_id FROM campaign_rewards ce
JOIN event_quests eq ON ce.item_id = eq.quest_id
WHERE eq.id = $1
`, pkt.QuestID).Scan(&campaignID)
if err == nil {
_, err = s.server.db.Exec(`INSERT INTO campaign_quest (campaign_id, character_id) VALUES ($1, $2)`, campaignID, s.charID)
if err != nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
}
}
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
}

View File

@@ -9,11 +9,6 @@ import (
"go.uber.org/zap"
)
func handleMsgMhfTransferItem(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfTransferItem)
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
}
func handleMsgMhfEnumeratePrice(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEnumeratePrice)
bf := byteframe.NewByteFrame()

View File

@@ -313,7 +313,34 @@ func makeEventQuest(s *Session, eq EventQuest) ([]byte, error) {
}
bf.WriteUint8(eq.QuestType)
if eq.QuestType == QuestTypeSpecialTool {
bf.WriteBool(false)
var stamps, required int
var deadline time.Time
err := s.server.db.QueryRow(`SELECT COUNT(*) FROM campaign_state WHERE campaign_id = (
SELECT campaign_id
FROM campaign_rewards
WHERE item_type = 9
AND item_id = $1
LIMIT 1
) AND character_id = $2`, eq.QuestID, s.charID).Scan(&stamps)
if err != nil {
bf.WriteBool(false)
} else {
err = s.server.db.QueryRow(`SELECT stamps, end_time
FROM campaigns
WHERE id = (
SELECT campaign_id
FROM campaign_rewards
WHERE item_type = 9
AND item_id = $1
LIMIT 1
)`, eq.QuestID).Scan(&required, &deadline)
required = campaignRequiredStamps(required)
if err == nil && stamps >= required && deadline.After(time.Now()) {
bf.WriteBool(true)
} else {
bf.WriteBool(false)
}
}
} else {
bf.WriteBool(true)
}