diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index e437368..3959032 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -20,6 +20,7 @@ import ( "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" + "github.com/denisovdennis/autohero/internal/version" ) var serverStartedAt = time.Now() @@ -2169,7 +2170,7 @@ func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Reques func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) { poolStat := h.pool.Stat() writeJSON(w, http.StatusOK, map[string]any{ - "version": "0.1.0-dev", + "version": version.Version, "goVersion": runtime.Version(), "uptimeMs": time.Since(serverStartedAt).Milliseconds(), "dbPool": map[string]any{ diff --git a/backend/internal/handler/api_time_middleware.go b/backend/internal/handler/api_time_middleware.go index c55c482..8a270a9 100644 --- a/backend/internal/handler/api_time_middleware.go +++ b/backend/internal/handler/api_time_middleware.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strings" "github.com/denisovdennis/autohero/internal/game" ) @@ -16,6 +17,11 @@ func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handle next.ServeHTTP(w, r) return } + // Client preference only; not part of combat/world simulation. + if strings.HasSuffix(r.URL.Path, "/hero/changelog/ack") { + next.ServeHTTP(w, r) + return + } if engine != nil && engine.IsTimePaused() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "server time is paused", diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 7c74b07..81acee4 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -15,10 +15,12 @@ import ( "github.com/go-chi/chi/v5" + "github.com/denisovdennis/autohero/internal/changelog" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" + "github.com/denisovdennis/autohero/internal/version" "github.com/denisovdennis/autohero/internal/world" ) @@ -853,13 +855,16 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() writeJSON(w, http.StatusOK, map[string]any{ - "hero": nil, - "needsName": true, - "offlineReport": nil, - "mapRef": h.world.RefForLevel(1), - "towns": townsWithNPCs, - "npcCostPotion": pCost, - "npcCostHeal": hCost, + "hero": nil, + "needsName": true, + "offlineReport": nil, + "mapRef": h.world.RefForLevel(1), + "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, + "serverVersion": version.Version, + "showChangelog": false, + "changelog": nil, }) return } @@ -919,17 +924,59 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { pCost, hCost := tuning.EffectiveNPCShopCosts() model.AttachDebuffCatalogForClient(hero) + + rel := changelog.ForVersion(version.Version) + showChangelog := rel != nil && hero.ChangelogAckVersion != version.Version + var changelogPayload any + if showChangelog && rel != nil { + changelogPayload = map[string]any{ + "title": rel.Title, + "items": rel.Items, + } + } + writeJSON(w, http.StatusOK, map[string]any{ - "hero": hero, - "needsName": needsName, - "offlineReport": report, - "mapRef": h.world.RefForLevel(hero.Level), - "towns": townsWithNPCs, - "npcCostPotion": pCost, - "npcCostHeal": hCost, + "hero": hero, + "needsName": needsName, + "offlineReport": report, + "mapRef": h.world.RefForLevel(hero.Level), + "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, + "serverVersion": version.Version, + "showChangelog": showChangelog, + "changelog": changelogPayload, }) } +// AckChangelog marks the current server changelog as seen for this hero. +// POST /api/v1/hero/changelog/ack +func (h *GameHandler) AckChangelog(w http.ResponseWriter, r *http.Request) { + telegramID, ok := resolveTelegramID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "missing telegramId", + }) + return + } + hero, err := h.store.GetByTelegramID(r.Context(), telegramID) + if err != nil { + h.logger.Error("changelog ack: get hero", "telegram_id", telegramID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) + return + } + if err := h.store.SetChangelogAckVersion(r.Context(), hero.ID, version.Version); err != nil { + h.logger.Error("changelog ack: save", "hero_id", hero.ID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // buildTownsWithNPCs loads all towns and their NPCs, returning a slice of // TownWithNPCs suitable for the frontend map render. diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 78e8aa6..0e1b7a9 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -73,8 +73,10 @@ type Hero struct { TownPause *TownPausePersisted `json:"-"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). + ChangelogAckVersion string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // BuffChargeState tracks the remaining free charges and period window for a single buff type. diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a53d0ae..fb13072 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -153,6 +153,7 @@ func New(deps Deps) *chi.Mux { r.Get("/hero", gameH.GetHero) r.Get("/hero/init", gameH.InitHero) + r.Post("/hero/changelog/ack", gameH.AckChangelog) r.Post("/hero/name", gameH.SetHeroName) r.Post("/hero/buff/{buffType}", gameH.ActivateBuff) r.Post("/hero/encounter", gameH.RequestEncounter) diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 60122fb..c7e56d1 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -31,7 +31,7 @@ const heroSelectQuery = ` h.position_x, h.position_y, h.potions, h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, h.current_town_id, h.destination_town_id, h.move_state, h.town_pause, - h.last_online_at, + h.last_online_at, h.changelog_ack_version, h.created_at, h.updated_at FROM heroes h ` @@ -606,6 +606,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { return nil } +// SetChangelogAckVersion records that the player has seen the changelog for the given server version. +func (s *HeroStore) SetChangelogAckVersion(ctx context.Context, heroID int64, v string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE heroes SET changelog_ack_version = $1, updated_at = now() WHERE id = $2 + `, v, heroID) + if err != nil { + return fmt.Errorf("set changelog ack: %w", err) + } + return nil +} + // SavePosition is a lightweight UPDATE that persists only the hero's world position. // Called frequently as the hero moves around the map. func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error { @@ -687,7 +698,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, - &h.LastOnlineAt, + &h.LastOnlineAt, &h.ChangelogAckVersion, &h.CreatedAt, &h.UpdatedAt, ) if err != nil { @@ -721,7 +732,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) { &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, - &h.LastOnlineAt, + &h.LastOnlineAt, &h.ChangelogAckVersion, &h.CreatedAt, &h.UpdatedAt, ) if err != nil { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc5ce58..394b5ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { import { ApiError, initHero, + ackChangelog, getAdventureLog, getTowns, getTownNPCs, @@ -32,7 +33,7 @@ import { defaultNpcShopCosts, npcShopCostsFromInit, } from './network/api'; -import type { HeroResponse, Achievement } from './network/api'; +import type { HeroResponse, Achievement, ChangelogPayload } from './network/api'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types'; import type { OfflineReport as OfflineReportData } from './network/api'; import { @@ -60,6 +61,7 @@ import { OfflineReport } from './ui/OfflineReport'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { NPCDialog } from './ui/NPCDialog'; import { NameEntryScreen } from './ui/NameEntryScreen'; +import { ChangelogModal } from './ui/ChangelogModal'; import { AchievementsPanel } from './ui/AchievementsPanel'; import { Minimap } from './ui/Minimap'; import { NPCInteraction } from './ui/NPCInteraction'; @@ -331,6 +333,9 @@ export function App() { const [combatLogLines, setCombatLogLines] = useState([]); const [offlineReport, setOfflineReport] = useState(null); const [needsName, setNeedsName] = useState(false); + type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string }; + const pendingChangelogRef = useRef(null); + const [changelogOpen, setChangelogOpen] = useState(null); const logIdCounter = useRef(0); const nearbyIntervalRef = useRef | null>(null); @@ -445,6 +450,18 @@ export function App() { const initRes = await initHero(telegramId); setNpcShopCosts(npcShopCostsFromInit(initRes)); + if (initRes.showChangelog && initRes.changelog) { + const bundle: ChangelogOpen = { + payload: initRes.changelog, + serverVersion: initRes.serverVersion, + }; + if (initRes.needsName) { + pendingChangelogRef.current = bundle; + } else { + setChangelogOpen(bundle); + } + } + // Gate game start behind name entry — no hero row until POST /hero/name if (initRes.needsName) { setNeedsName(true); @@ -1055,9 +1072,20 @@ export function App() { getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => {}); + + if (pendingChangelogRef.current) { + setChangelogOpen(pendingChangelogRef.current); + pendingChangelogRef.current = null; + } } }, []); + const handleDismissChangelog = useCallback(() => { + setChangelogOpen(null); + const telegramId = getTelegramUserId() ?? 1; + ackChangelog(telegramId).catch(() => console.warn('[App] changelog ack failed')); + }, []); + const handleUsePotion = useCallback(() => { const ws = wsRef.current; const hero = engineRef.current?.gameState.hero; @@ -1226,6 +1254,15 @@ export function App() { {/* Name Entry Screen */} {needsName && } + {changelogOpen && ( + + )} + {/* Death Screen */} { return apiGet(`/hero/init${query}`); } +/** Mark the current server changelog as read (call after the user dismisses the modal). */ +export async function ackChangelog(telegramId?: number): Promise { + const query = telegramId != null ? `?telegramId=${telegramId}` : ''; + await apiPost<{ ok?: boolean }>(`/hero/changelog/ack${query}`); +} + /** Set the hero's display name (first time only). Returns updated hero on success. */ export async function setHeroName(name: string, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : '';