new town logic

master
Denis Ranneft 1 month ago
parent 406cfab102
commit dd1d09e87d

@ -70,6 +70,15 @@ type HeroMovement struct {
TownVisitStartedAt time.Time
TownVisitLogsEmitted int
// Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires.
TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking)
TownNPCWalkFromX float64
TownNPCWalkFromY float64
TownNPCWalkToX float64
TownNPCWalkToY float64
TownNPCWalkStart time.Time // when walk began
TownNPCWalkArrive time.Time // when hero reaches NPC
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
TownLeaveAt time.Time
@ -615,6 +624,7 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.clearNPCWalk()
t := graph.Towns[townID]
hm.CurrentX = t.WorldX
hm.CurrentY = t.WorldY
@ -715,6 +725,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownRestHealRemainder = 0
hm.clearNPCWalk()
if graph != nil && hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
@ -846,6 +857,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.clearNPCWalk()
ids := graph.TownNPCIDs(destID)
if len(ids) == 0 {
@ -879,6 +891,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.clearNPCWalk()
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
@ -899,6 +912,17 @@ func randomTownNPCDelay() time.Duration {
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// clearNPCWalk resets the walk-to-NPC sub-state.
func (hm *HeroMovement) clearNPCWalk() {
hm.TownNPCWalkTargetID = 0
hm.TownNPCWalkFromX = 0
hm.TownNPCWalkFromY = 0
hm.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0
hm.TownNPCWalkStart = time.Time{}
hm.TownNPCWalkArrive = time.Time{}
}
// StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() {
hm.State = model.StateFighting
@ -1019,6 +1043,19 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
NPCWalkTargetID: hm.TownNPCWalkTargetID,
NPCWalkFromX: hm.TownNPCWalkFromX,
NPCWalkFromY: hm.TownNPCWalkFromY,
NPCWalkToX: hm.TownNPCWalkToX,
NPCWalkToY: hm.TownNPCWalkToY,
}
if !hm.TownNPCWalkStart.IsZero() {
t := hm.TownNPCWalkStart
p.NPCWalkStart = &t
}
if !hm.TownNPCWalkArrive.IsZero() {
t := hm.TownNPCWalkArrive
p.NPCWalkArrive = &t
}
if len(hm.TownNPCQueue) > 0 {
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
@ -1111,6 +1148,17 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkFromX = blob.NPCWalkFromX
hm.TownNPCWalkFromY = blob.NPCWalkFromY
hm.TownNPCWalkToX = blob.NPCWalkToX
hm.TownNPCWalkToY = blob.NPCWalkToY
if blob.NPCWalkStart != nil {
hm.TownNPCWalkStart = *blob.NPCWalkStart
}
if blob.NPCWalkArrive != nil {
hm.TownNPCWalkArrive = *blob.NPCWalkArrive
}
}
// Restore excursion session from blob (may exist alongside any hero state).
@ -1638,8 +1686,75 @@ func ProcessSingleHeroMovementTick(
case model.StateInTown:
cfg := tuning.Get()
// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
hm.LastMoveTick = now
// --- Sub-state: hero is walking toward an NPC inside the town ---
if hm.TownNPCWalkTargetID != 0 {
if !now.Before(hm.TownNPCWalkArrive) {
// Arrived at NPC — snap position and fire the visit event.
hm.CurrentX = hm.TownNPCWalkToX
hm.CurrentY = hm.TownNPCWalkToY
npcID := hm.TownNPCWalkTargetID
npcWX := hm.TownNPCWalkToX
npcWY := hm.TownNPCWalkToY
hm.clearNPCWalk()
if npc, ok := graph.NPCByID[npcID]; ok {
if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
WorldX: npcWX, WorldY: npcWY,
})
}
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
if npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
// Still walking — interpolate position.
totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds()
if totalMs <= 0 {
totalMs = 1
}
elapsed := now.Sub(hm.TownNPCWalkStart).Milliseconds()
t := float64(elapsed) / float64(totalMs)
if t > 1 {
t = 1
}
hm.CurrentX = hm.TownNPCWalkFromX + (hm.TownNPCWalkToX-hm.TownNPCWalkFromX)*t
hm.CurrentY = hm.TownNPCWalkFromY + (hm.TownNPCWalkToY-hm.TownNPCWalkFromY)*t
if sender != nil {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
dx := hm.TownNPCWalkToX - hm.CurrentX
dy := hm.TownNPCWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
}
hm.SyncToHero()
return
}
// NPC visit pause ended: clear visit log state before the next roll.
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
hm.TownVisitNPCName = ""
@ -1649,7 +1764,7 @@ func ProcessSingleHeroMovementTick(
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
if len(hm.TownNPCQueue) == 0 {
if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 {
if hm.TownLeaveAt.IsZero() {
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
}
@ -1676,26 +1791,39 @@ func ProcessSingleHeroMovementTick(
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok {
npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID)
if !posOk {
if town := graph.Towns[hm.CurrentTownID]; town != nil {
npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY
}
}
dx := npcWX - hm.CurrentX
dy := npcWY - hm.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
const minWalkMs = 300
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
if walkDur < minWalkMs*time.Millisecond {
walkDur = minWalkMs * time.Millisecond
}
hm.TownNPCWalkTargetID = npcID
hm.TownNPCWalkFromX = hm.CurrentX
hm.TownNPCWalkFromY = hm.CurrentY
hm.TownNPCWalkToX = npcWX
hm.TownNPCWalkToY = npcWY
hm.TownNPCWalkStart = now
hm.TownNPCWalkArrive = now.Add(walkDur)
if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: npcWX, TargetY: npcWY,
Speed: walkSpeed, Heading: heading,
})
}
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
if npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {

@ -32,6 +32,8 @@ type TownNPC struct {
Name string
Type string
BuildingID *int64
OffsetX float64
OffsetY float64
}
// TownBuilding is a building placed in a town (from town_buildings table).
@ -89,7 +91,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate towns: %w", err)
}
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id FROM npcs ORDER BY town_id, id`)
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`)
if err != nil {
return nil, fmt.Errorf("load npcs: %w", err)
}
@ -97,7 +99,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for npcRows.Next() {
var n TownNPC
var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID); err != nil {
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
g.NPCByID[n.ID] = n
@ -180,21 +182,40 @@ func (g *RoadGraph) TownBuildingInfos(townID int64) []model.TownBuildingInfo {
return infos
}
// TownNPCInfos returns NPC payloads for the given town, including building IDs.
// TownNPCInfos returns NPC payloads for the given town, including building IDs and world positions.
func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo {
town := g.Towns[townID]
npcs := g.TownNPCs[townID]
infos := make([]model.TownNPCInfo, 0, len(npcs))
for _, n := range npcs {
infos = append(infos, model.TownNPCInfo{
info := model.TownNPCInfo{
ID: n.ID,
Name: n.Name,
Type: n.Type,
BuildingID: n.BuildingID,
})
}
if town != nil {
info.WorldX = town.WorldX + n.OffsetX
info.WorldY = town.WorldY + n.OffsetY
}
infos = append(infos, info)
}
return infos
}
// NPCWorldPos returns the absolute world position of an NPC using its town center + offset.
func (g *RoadGraph) NPCWorldPos(npcID, townID int64) (worldX, worldY float64, ok bool) {
npc, found := g.NPCByID[npcID]
if !found {
return 0, 0, false
}
town := g.Towns[townID]
if town == nil {
return 0, 0, false
}
return town.WorldX + npc.OffsetX, town.WorldY + npc.OffsetY, true
}
// TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues).
func (g *RoadGraph) TownNPCIDs(townID int64) []int64 {
list := g.TownNPCs[townID]

@ -19,6 +19,15 @@ type TownPausePersisted struct {
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
// Walk-to-NPC: hero is mid-walk toward an NPC inside the town.
NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"`
NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"`
NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"`
}

@ -120,6 +120,8 @@ type TownNPCInfo struct {
Name string `json:"name"`
Type string `json:"type"`
BuildingID *int64 `json:"buildingId,omitempty"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
}
// TownBuildingInfo describes a building in a town (town_enter payload).
@ -143,12 +145,14 @@ type TownEnterPayload struct {
RestDurationMs int64 `json:"restDurationMs"`
}
// TownNPCVisitPayload is sent when the hero approaches an NPC (quest/shop/healer) during a town stay.
// TownNPCVisitPayload is sent when the hero arrives at an NPC (quest/shop/healer) during a town stay.
type TownNPCVisitPayload struct {
NPCID int64 `json:"npcId"`
Name string `json:"name"`
Type string `json:"type"`
TownID int64 `json:"townId"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
}
// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.

@ -28,6 +28,7 @@ type Values struct {
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"`
@ -214,6 +215,7 @@ func DefaultValues() Values {
TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000,
TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0,
WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20,
MerchantCostPerLevel: 5,

@ -641,18 +641,8 @@ export function App() {
setCurrentTown(town);
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`);
const npcs = p.npcs ?? [];
if (npcs.length > 0) {
const firstNPC = npcs[0]!;
setNearestNPC({
id: firstNPC.id,
name: firstNPC.name,
type: firstNPC.type as NPCData['type'],
worldX: 0,
worldY: 0,
});
setNearestNPC(null);
setNpcInteractionDismissed(null);
}
},
onAdventureLogLine: (p) => {
@ -667,8 +657,8 @@ export function App() {
id: p.npcId,
name: p.name,
type: p.type as NPCData['type'],
worldX: 0,
worldY: 0,
worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0,
});
setNpcInteractionDismissed(null);
},

@ -494,7 +494,7 @@ export interface TownEnterPayload {
townId: number;
townName: string;
biome?: string;
npcs?: Array<{ id: number; name: string; type: string; buildingId?: number }>;
npcs?: Array<{ id: number; name: string; type: string; buildingId?: number; worldX: number; worldY: number }>;
buildings?: Array<{
id: number;
buildingType: string;
@ -512,6 +512,8 @@ export interface TownNPCVisitPayload {
name: string;
type: string;
townId: number;
worldX: number;
worldY: number;
}
/** Server-persisted adventure log line (e.g. town NPC visit narration). */

Loading…
Cancel
Save