changelog feature

master
Denis Ranneft 1 month ago
parent 7588995c83
commit f0f610eb36

@ -20,6 +20,7 @@ import (
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
) )
var serverStartedAt = time.Now() 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) { func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) {
poolStat := h.pool.Stat() poolStat := h.pool.Stat()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"version": "0.1.0-dev", "version": version.Version,
"goVersion": runtime.Version(), "goVersion": runtime.Version(),
"uptimeMs": time.Since(serverStartedAt).Milliseconds(), "uptimeMs": time.Since(serverStartedAt).Milliseconds(),
"dbPool": map[string]any{ "dbPool": map[string]any{

@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"strings"
"github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/game"
) )
@ -16,6 +17,11 @@ func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handle
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return 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() { if engine != nil && engine.IsTimePaused() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{ writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "server time is paused", "error": "server time is paused",

@ -15,10 +15,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/changelog"
"github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
"github.com/denisovdennis/autohero/internal/world" "github.com/denisovdennis/autohero/internal/world"
) )
@ -860,6 +862,9 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
}) })
return return
} }
@ -919,6 +924,17 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
model.AttachDebuffCatalogForClient(hero) 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{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"needsName": needsName, "needsName": needsName,
@ -927,9 +943,40 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "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 // buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render. // TownWithNPCs suitable for the frontend map render.

@ -73,6 +73,8 @@ type Hero struct {
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }

@ -153,6 +153,7 @@ func New(deps Deps) *chi.Mux {
r.Get("/hero", gameH.GetHero) r.Get("/hero", gameH.GetHero)
r.Get("/hero/init", gameH.InitHero) r.Get("/hero/init", gameH.InitHero)
r.Post("/hero/changelog/ack", gameH.AckChangelog)
r.Post("/hero/name", gameH.SetHeroName) r.Post("/hero/name", gameH.SetHeroName)
r.Post("/hero/buff/{buffType}", gameH.ActivateBuff) r.Post("/hero/buff/{buffType}", gameH.ActivateBuff)
r.Post("/hero/encounter", gameH.RequestEncounter) r.Post("/hero/encounter", gameH.RequestEncounter)

@ -31,7 +31,7 @@ const heroSelectQuery = `
h.position_x, h.position_y, h.potions, 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.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.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 h.created_at, h.updated_at
FROM heroes h FROM heroes h
` `
@ -606,6 +606,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
return nil 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. // SavePosition is a lightweight UPDATE that persists only the hero's world position.
// Called frequently as the hero moves around the map. // Called frequently as the hero moves around the map.
func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error { 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.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {
@ -721,7 +732,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {

@ -16,6 +16,7 @@ import {
import { import {
ApiError, ApiError,
initHero, initHero,
ackChangelog,
getAdventureLog, getAdventureLog,
getTowns, getTowns,
getTownNPCs, getTownNPCs,
@ -32,7 +33,7 @@ import {
defaultNpcShopCosts, defaultNpcShopCosts,
npcShopCostsFromInit, npcShopCostsFromInit,
} from './network/api'; } 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 { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api'; import type { OfflineReport as OfflineReportData } from './network/api';
import { import {
@ -60,6 +61,7 @@ import { OfflineReport } from './ui/OfflineReport';
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
import { NPCDialog } from './ui/NPCDialog'; import { NPCDialog } from './ui/NPCDialog';
import { NameEntryScreen } from './ui/NameEntryScreen'; import { NameEntryScreen } from './ui/NameEntryScreen';
import { ChangelogModal } from './ui/ChangelogModal';
import { AchievementsPanel } from './ui/AchievementsPanel'; import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap'; import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction'; import { NPCInteraction } from './ui/NPCInteraction';
@ -331,6 +333,9 @@ export function App() {
const [combatLogLines, setCombatLogLines] = useState<string[]>([]); const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null); const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
const [needsName, setNeedsName] = useState(false); const [needsName, setNeedsName] = useState(false);
type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string };
const pendingChangelogRef = useRef<ChangelogOpen | null>(null);
const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
const logIdCounter = useRef(0); const logIdCounter = useRef(0);
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -445,6 +450,18 @@ export function App() {
const initRes = await initHero(telegramId); const initRes = await initHero(telegramId);
setNpcShopCosts(npcShopCostsFromInit(initRes)); 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 // Gate game start behind name entry — no hero row until POST /hero/name
if (initRes.needsName) { if (initRes.needsName) {
setNeedsName(true); setNeedsName(true);
@ -1055,7 +1072,18 @@ export function App() {
getAchievements(telegramId) getAchievements(telegramId)
.then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
.catch(() => {}); .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 handleUsePotion = useCallback(() => {
@ -1226,6 +1254,15 @@ export function App() {
{/* Name Entry Screen */} {/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />} {needsName && <NameEntryScreen onNameSet={handleNameSet} />}
{changelogOpen && (
<ChangelogModal
title={changelogOpen.payload.title}
items={changelogOpen.payload.items}
serverVersion={changelogOpen.serverVersion}
onDismiss={handleDismissChangelog}
/>
)}
{/* Death Screen */} {/* Death Screen */}
<DeathScreen <DeathScreen
visible={gameState.phase === GamePhase.Dead} visible={gameState.phase === GamePhase.Dead}

@ -185,6 +185,11 @@ export const en = {
quests: 'Quests', quests: 'Quests',
hero: 'Hero', hero: 'Hero',
// Changelog (server release notes)
changelogTitle: "What's new",
changelogOk: 'Got it',
changelogVersion: 'Version {version}',
// Settings // Settings
settings: 'Settings', settings: 'Settings',
language: 'Language', language: 'Language',

@ -189,6 +189,11 @@ export const ru: Translations = {
quests: '\u041a\u0432\u0435\u0441\u0442\u044b', quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439', hero: '\u0413\u0435\u0440\u043e\u0439',
// Changelog
changelogTitle: '\u0427\u0442\u043e \u043d\u043e\u0432\u043e\u0433\u043e',
changelogOk: '\u041f\u043e\u043d\u044f\u0442\u043d\u043e',
changelogVersion: '\u0412\u0435\u0440\u0441\u0438\u044f {version}',
// Settings // Settings
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438', settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a', language: '\u042f\u0437\u044b\u043a',

@ -182,6 +182,12 @@ export interface OfflineReport {
message: string; message: string;
} }
/** Curated release notes for the current server version (see backend changelog.json). */
export interface ChangelogPayload {
title: string;
items: string[];
}
export interface InitHeroResponse { export interface InitHeroResponse {
/** Null until the player submits a valid name (no DB row until then). */ /** Null until the player submits a valid name (no DB row until then). */
hero: HeroResponse | null; hero: HeroResponse | null;
@ -192,6 +198,11 @@ export interface InitHeroResponse {
npcCostPotion?: number; npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */ /** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number; npcCostHeal?: number;
/** Server build id; bump on backend with changelog entry to show the modal. */
serverVersion?: string;
/** True when there is a changelog entry for serverVersion and the player has not ack'd yet. */
showChangelog?: boolean;
changelog?: ChangelogPayload | null;
} }
/** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */ /** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */
@ -215,6 +226,12 @@ export async function initHero(telegramId?: number): Promise<InitHeroResponse> {
return apiGet<InitHeroResponse>(`/hero/init${query}`); return apiGet<InitHeroResponse>(`/hero/init${query}`);
} }
/** Mark the current server changelog as read (call after the user dismisses the modal). */
export async function ackChangelog(telegramId?: number): Promise<void> {
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. */ /** Set the hero's display name (first time only). Returns updated hero on success. */
export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> { export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const query = telegramId != null ? `?telegramId=${telegramId}` : '';

Loading…
Cancel
Save