You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
648 lines
22 KiB
Go
648 lines
22 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
)
|
|
|
|
// QuestStore handles quest system CRUD operations against PostgreSQL.
|
|
type QuestStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewQuestStore creates a new QuestStore backed by the given connection pool.
|
|
func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
|
|
return &QuestStore{pool: pool}
|
|
}
|
|
|
|
// ListTowns returns all towns ordered by level_min ascending.
|
|
func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max
|
|
FROM towns
|
|
ORDER BY level_min ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list towns: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var towns []model.Town
|
|
for rows.Next() {
|
|
var t model.Town
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
|
|
return nil, fmt.Errorf("scan town: %w", err)
|
|
}
|
|
towns = append(towns, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list towns rows: %w", err)
|
|
}
|
|
if towns == nil {
|
|
towns = []model.Town{}
|
|
}
|
|
return towns, nil
|
|
}
|
|
|
|
// GetTown loads a single town by ID. Returns (nil, nil) if not found.
|
|
func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, error) {
|
|
var t model.Town
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max
|
|
FROM towns WHERE id = $1
|
|
`, townID).Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("get town: %w", err)
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// ListNPCsByTown returns all NPCs in the given town.
|
|
func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id
|
|
FROM npcs
|
|
WHERE town_id = $1
|
|
ORDER BY id ASC
|
|
`, townID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list npcs by town: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var npcs []model.NPC
|
|
for rows.Next() {
|
|
var n model.NPC
|
|
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
|
|
return nil, fmt.Errorf("scan npc: %w", err)
|
|
}
|
|
npcs = append(npcs, n)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list npcs rows: %w", err)
|
|
}
|
|
if npcs == nil {
|
|
npcs = []model.NPC{}
|
|
}
|
|
return npcs, nil
|
|
}
|
|
|
|
// GetNPCByID loads a single NPC by primary key. Returns (nil, nil) if not found.
|
|
func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) {
|
|
var n model.NPC
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id
|
|
FROM npcs WHERE id = $1
|
|
`, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("get npc by id: %w", err)
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
// ListAllNPCs returns every NPC across all towns.
|
|
func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id
|
|
FROM npcs
|
|
ORDER BY town_id ASC, id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list all npcs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var npcs []model.NPC
|
|
for rows.Next() {
|
|
var n model.NPC
|
|
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
|
|
return nil, fmt.Errorf("scan npc: %w", err)
|
|
}
|
|
npcs = append(npcs, n)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list all npcs rows: %w", err)
|
|
}
|
|
if npcs == nil {
|
|
npcs = []model.NPC{}
|
|
}
|
|
return npcs, nil
|
|
}
|
|
|
|
// ListBuildingsByTown returns all buildings in the given town.
|
|
func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]model.TownBuilding, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
|
|
FROM town_buildings
|
|
WHERE town_id = $1
|
|
ORDER BY id ASC
|
|
`, townID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list buildings by town: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var buildings []model.TownBuilding
|
|
for rows.Next() {
|
|
var b model.TownBuilding
|
|
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
|
|
return nil, fmt.Errorf("scan building: %w", err)
|
|
}
|
|
buildings = append(buildings, b)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list buildings rows: %w", err)
|
|
}
|
|
if buildings == nil {
|
|
buildings = []model.TownBuilding{}
|
|
}
|
|
return buildings, nil
|
|
}
|
|
|
|
// ListAllBuildings returns every building across all towns (for road_graph preload).
|
|
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
|
|
FROM town_buildings
|
|
ORDER BY town_id ASC, id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list all buildings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var buildings []model.TownBuilding
|
|
for rows.Next() {
|
|
var b model.TownBuilding
|
|
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
|
|
return nil, fmt.Errorf("scan building: %w", err)
|
|
}
|
|
buildings = append(buildings, b)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list all buildings rows: %w", err)
|
|
}
|
|
if buildings == nil {
|
|
buildings = []model.TownBuilding{}
|
|
}
|
|
return buildings, nil
|
|
}
|
|
|
|
// ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range.
|
|
func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count,
|
|
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
|
|
min_level, max_level, reward_xp, reward_gold, reward_potions
|
|
FROM quests
|
|
WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level
|
|
ORDER BY min_level ASC, id ASC
|
|
`, npcID, heroLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list quests by npc for level: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var quests []model.Quest
|
|
for rows.Next() {
|
|
var q model.Quest
|
|
if err := rows.Scan(
|
|
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount,
|
|
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
|
|
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan quest: %w", err)
|
|
}
|
|
quests = append(quests, q)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list quests by npc for level rows: %w", err)
|
|
}
|
|
if quests == nil {
|
|
quests = []model.Quest{}
|
|
}
|
|
return quests, nil
|
|
}
|
|
|
|
// HeroTakenQuestTemplateIDs returns quest template ids already present in the hero's log (any status).
|
|
func (s *QuestStore) HeroTakenQuestTemplateIDs(ctx context.Context, heroID int64) ([]int64, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT quest_id FROM hero_quests WHERE hero_id = $1`, heroID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hero taken quest ids: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("scan quest_id: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("hero taken quest ids rows: %w", err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// ListOfferableQuestsForNPC returns level-matching NPC quests excluding templates already on the hero's log.
|
|
// limit comes from tuning (e.g. questOffersPerNPC). timeBucket is a stable bucket (e.g. unixSeconds/7200) for rotations.
|
|
func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcID int64, heroLevel int, limit int, timeBucket int64) ([]model.Quest, error) {
|
|
all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
taken := make(map[int64]struct{}, len(takenIDs))
|
|
for _, id := range takenIDs {
|
|
taken[id] = struct{}{}
|
|
}
|
|
seed := npcID ^ timeBucket
|
|
return FilterCapOfferableQuests(all, taken, limit, seed), nil
|
|
}
|
|
|
|
// ListQuestsByNPC returns all quest templates offered by the given NPC.
|
|
func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count,
|
|
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
|
|
min_level, max_level, reward_xp, reward_gold, reward_potions
|
|
FROM quests
|
|
WHERE npc_id = $1
|
|
ORDER BY min_level ASC, id ASC
|
|
`, npcID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list quests by npc: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var quests []model.Quest
|
|
for rows.Next() {
|
|
var q model.Quest
|
|
if err := rows.Scan(
|
|
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount,
|
|
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
|
|
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan quest: %w", err)
|
|
}
|
|
quests = append(quests, q)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list quests rows: %w", err)
|
|
}
|
|
if quests == nil {
|
|
quests = []model.Quest{}
|
|
}
|
|
return quests, nil
|
|
}
|
|
|
|
// ListAllQuestTemplates returns every quest template row (content catalog).
|
|
func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count,
|
|
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
|
|
min_level, max_level, reward_xp, reward_gold, reward_potions
|
|
FROM quests
|
|
ORDER BY id ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list all quest templates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var quests []model.Quest
|
|
for rows.Next() {
|
|
var q model.Quest
|
|
if err := rows.Scan(
|
|
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount,
|
|
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
|
|
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan quest: %w", err)
|
|
}
|
|
quests = append(quests, q)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list all quest templates rows: %w", err)
|
|
}
|
|
if quests == nil {
|
|
quests = []model.Quest{}
|
|
}
|
|
return quests, nil
|
|
}
|
|
|
|
// UpdateQuestTemplate updates a quest definition row by id.
|
|
func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) error {
|
|
if q == nil || q.ID <= 0 {
|
|
return fmt.Errorf("invalid quest id")
|
|
}
|
|
cmd, err := s.pool.Exec(ctx, `
|
|
UPDATE quests SET
|
|
npc_id = $2, quest_key = NULLIF($3, ''), title = $4, description = $5, type = $6, target_count = $7,
|
|
target_enemy_type = $8, target_enemy_archetype = $9, target_town_id = $10, drop_chance = $11,
|
|
min_level = $12, max_level = $13, reward_xp = $14, reward_gold = $15, reward_potions = $16
|
|
WHERE id = $1
|
|
`,
|
|
q.ID, q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount,
|
|
q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance,
|
|
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update quest: %w", err)
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return fmt.Errorf("quest not found: %d", q.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateQuestTemplate inserts a new quest row and sets q.ID.
|
|
func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) error {
|
|
if q == nil {
|
|
return fmt.Errorf("nil quest")
|
|
}
|
|
if q.NPCID <= 0 || strings.TrimSpace(q.Title) == "" || q.Type == "" {
|
|
return fmt.Errorf("npcId, title and type are required")
|
|
}
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO quests (npc_id, quest_key, title, description, type, target_count,
|
|
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
|
|
min_level, max_level, reward_xp, reward_gold, reward_potions)
|
|
VALUES ($1, NULLIF($2, ''), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING id
|
|
`,
|
|
q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount,
|
|
q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance,
|
|
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
|
|
).Scan(&q.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("create quest: %w", err)
|
|
}
|
|
if q.QuestKey == "" {
|
|
q.QuestKey = fmt.Sprintf("quest.%d", q.ID)
|
|
_, _ = s.pool.Exec(ctx, `UPDATE quests SET quest_key = $2 WHERE id = $1`, q.ID, q.QuestKey)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AcceptQuest creates a hero_quests row for the given hero and quest.
|
|
// Returns an error if the quest is already present in the log (any status).
|
|
func (s *QuestStore) AcceptQuest(ctx context.Context, heroID int64, questID int64) error {
|
|
inserted, err := s.TryAcceptQuest(ctx, heroID, questID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !inserted {
|
|
return fmt.Errorf("quest already in log")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TryAcceptQuest inserts an accepted hero_quest row when none exists yet. Returns whether a row was inserted.
|
|
func (s *QuestStore) TryAcceptQuest(ctx context.Context, heroID int64, questID int64) (bool, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
INSERT INTO hero_quests (hero_id, quest_id, status, progress, accepted_at)
|
|
VALUES ($1, $2, 'accepted', 0, now())
|
|
ON CONFLICT (hero_id, quest_id) DO NOTHING
|
|
`, heroID, questID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("try accept quest: %w", err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// ListHeroQuests returns all quests for the hero with their quest template joined.
|
|
func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.HeroQuest, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress,
|
|
hq.accepted_at, hq.completed_at, hq.claimed_at,
|
|
q.id, q.npc_id, COALESCE(q.quest_key, ''), q.title, q.description, q.type, q.target_count,
|
|
q.target_enemy_type, q.target_enemy_archetype, q.target_town_id,
|
|
COALESCE(tt.name, '') AS target_town_name,
|
|
q.drop_chance,
|
|
q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions
|
|
FROM hero_quests hq
|
|
JOIN quests q ON hq.quest_id = q.id
|
|
LEFT JOIN towns tt ON tt.id = q.target_town_id
|
|
WHERE hq.hero_id = $1
|
|
ORDER BY hq.accepted_at DESC
|
|
`, heroID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hero quests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var hqs []model.HeroQuest
|
|
for rows.Next() {
|
|
var hq model.HeroQuest
|
|
var q model.Quest
|
|
if err := rows.Scan(
|
|
&hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress,
|
|
&hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt,
|
|
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount,
|
|
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance,
|
|
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan hero quest: %w", err)
|
|
}
|
|
hq.Quest = &q
|
|
hqs = append(hqs, hq)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list hero quests rows: %w", err)
|
|
}
|
|
if hqs == nil {
|
|
hqs = []model.HeroQuest{}
|
|
}
|
|
return hqs, nil
|
|
}
|
|
|
|
// IncrementQuestProgress increments progress for all matching accepted quests.
|
|
// For kill_count: objectiveType="kill_count"; a quest matches when both non-null filters hold:
|
|
// (target_enemy_type IS NULL OR = enemySlug) AND (target_enemy_archetype IS NULL OR = enemyArchetype).
|
|
func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, enemySlug, enemyArchetype string, delta int) error {
|
|
if delta <= 0 {
|
|
return nil
|
|
}
|
|
|
|
query := `
|
|
UPDATE hero_quests hq
|
|
SET progress = LEAST(progress + $3, q.target_count),
|
|
status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END,
|
|
completed_at = CASE WHEN progress + $3 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END
|
|
FROM quests q
|
|
WHERE hq.quest_id = q.id
|
|
AND hq.hero_id = $1
|
|
AND hq.status = 'accepted'
|
|
AND q.type = $2
|
|
AND (q.target_enemy_type IS NULL OR q.target_enemy_type = $4)
|
|
AND (q.target_enemy_archetype IS NULL OR q.target_enemy_archetype = $5)
|
|
`
|
|
_, err := s.pool.Exec(ctx, query, heroID, objectiveType, delta, enemySlug, enemyArchetype)
|
|
if err != nil {
|
|
return fmt.Errorf("increment quest progress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClaimQuestReward marks a completed quest as claimed and returns the rewards.
|
|
// heroQuestID is hero_quests.id (quest log row), not the quests template id.
|
|
// Returns an error if the quest is not in 'completed' status.
|
|
func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, heroQuestID int64) (*model.QuestReward, error) {
|
|
var reward model.QuestReward
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
UPDATE hero_quests hq
|
|
SET status = 'claimed', claimed_at = now()
|
|
FROM quests q
|
|
WHERE hq.quest_id = q.id
|
|
AND hq.hero_id = $1
|
|
AND hq.id = $2
|
|
AND hq.status = 'completed'
|
|
RETURNING q.reward_xp, q.reward_gold, q.reward_potions
|
|
`, heroID, heroQuestID).Scan(&reward.XP, &reward.Gold, &reward.Potions)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("quest not found or not in completed status")
|
|
}
|
|
return nil, fmt.Errorf("claim quest reward: %w", err)
|
|
}
|
|
|
|
return &reward, nil
|
|
}
|
|
|
|
// DeleteAllHeroQuests removes every quest log row for the hero (accepted/completed/claimed).
|
|
func (s *QuestStore) DeleteAllHeroQuests(ctx context.Context, heroID int64) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM hero_quests WHERE hero_id = $1`, heroID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete all hero quests: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AbandonQuest removes a hero's quest log row. heroQuestID is hero_quests.id (same id the client uses for claim).
|
|
// Only accepted/completed quests can be abandoned (not already claimed).
|
|
func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, heroQuestID int64) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
DELETE FROM hero_quests
|
|
WHERE hero_id = $1 AND id = $2 AND status != 'claimed'
|
|
`, heroID, heroQuestID)
|
|
if err != nil {
|
|
return fmt.Errorf("abandon quest: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("quest not found or already claimed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IncrementVisitTownProgress updates visit_town quests when a hero enters a town.
|
|
func (s *QuestStore) IncrementVisitTownProgress(ctx context.Context, heroID int64, townID int64) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE hero_quests hq
|
|
SET progress = LEAST(progress + 1, q.target_count),
|
|
status = CASE WHEN progress + 1 >= q.target_count THEN 'completed' ELSE status END,
|
|
completed_at = CASE WHEN progress + 1 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END
|
|
FROM quests q
|
|
WHERE hq.quest_id = q.id
|
|
AND hq.hero_id = $1
|
|
AND hq.status = 'accepted'
|
|
AND q.type = 'visit_town'
|
|
AND q.target_town_id = $2
|
|
`, heroID, townID)
|
|
if err != nil {
|
|
return fmt.Errorf("increment visit town progress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance.
|
|
// Called after a kill; each matching quest gets a roll for each delta kill.
|
|
func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error {
|
|
// Fetch active collect_item quests for this hero
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type, q.target_enemy_archetype
|
|
FROM hero_quests hq
|
|
JOIN quests q ON hq.quest_id = q.id
|
|
WHERE hq.hero_id = $1
|
|
AND hq.status = 'accepted'
|
|
AND q.type = 'collect_item'
|
|
`, heroID)
|
|
if err != nil {
|
|
return fmt.Errorf("list collect item quests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type collectQuest struct {
|
|
hqID int64
|
|
targetCount int
|
|
progress int
|
|
dropChance float64
|
|
targetEnemyType *string
|
|
targetEnemyArchetype *string
|
|
}
|
|
var cqs []collectQuest
|
|
for rows.Next() {
|
|
var cq collectQuest
|
|
if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType, &cq.targetEnemyArchetype); err != nil {
|
|
return fmt.Errorf("scan collect quest: %w", err)
|
|
}
|
|
cqs = append(cqs, cq)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("collect item quests rows: %w", err)
|
|
}
|
|
|
|
for _, cq := range cqs {
|
|
if cq.targetEnemyType != nil && *cq.targetEnemyType != enemySlug {
|
|
continue
|
|
}
|
|
if cq.targetEnemyArchetype != nil && *cq.targetEnemyArchetype != enemyArchetype {
|
|
continue
|
|
}
|
|
if cq.progress >= cq.targetCount {
|
|
continue
|
|
}
|
|
// Roll the drop chance
|
|
roll := randFloat64()
|
|
if roll >= cq.dropChance {
|
|
continue
|
|
}
|
|
// Increment progress by 1
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE hero_quests
|
|
SET progress = LEAST(progress + 1, $2),
|
|
status = CASE WHEN progress + 1 >= $2 THEN 'completed' ELSE status END,
|
|
completed_at = CASE WHEN progress + 1 >= $2 AND completed_at IS NULL THEN now() ELSE completed_at END
|
|
WHERE id = $1
|
|
`, cq.hqID, cq.targetCount)
|
|
if err != nil {
|
|
return fmt.Errorf("update collect item progress: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// randFloat64 wraps rand.Float64 for testability.
|
|
var randFloat64 = rand.Float64
|