master
Denis Ranneft 1 month ago
parent 1e6b6b29b7
commit bc510fb9c7

@ -65,22 +65,6 @@ func (e *Engine) syncHeroMeetPartnerExcursion(leader *HeroMovement, partner *Her
partner.Excursion.HeroMeetPartnerID = lid
}
func randomHeroMeetOfflineDuration(cfg tuning.Values) time.Duration {
minMs := cfg.HeroMeetOfflineMinMs
maxMs := cfg.HeroMeetOfflineMaxMs
if minMs <= 0 {
minMs = tuning.DefaultValues().HeroMeetOfflineMinMs
}
if maxMs <= 0 {
maxMs = tuning.DefaultValues().HeroMeetOfflineMaxMs
}
if maxMs < minMs {
maxMs = minMs
}
delta := maxMs - minMs
return time.Duration(minMs+rand.Int63n(delta+1)) * time.Millisecond
}
func heroMeetHeroNearAttractor(hm *HeroMovement) bool {
if hm == nil || !hm.Excursion.AttractorSet {
return false
@ -102,7 +86,7 @@ func (e *Engine) transitionHeroMeetDialogueTimersLocked(lo, hi int64, now time.T
anyOnline := e.heroMeetAnySubscriberOnline(lo, hi)
offlineBudget := time.Duration(ex.HeroMeetOfflineRemainingMs) * time.Millisecond
if offlineBudget <= 0 {
offlineBudget = randomHeroMeetOfflineDuration(cfg)
offlineBudget = randomDurationBetweenMs(240_000, 360_000)
ex.HeroMeetOfflineRemainingMs = offlineBudget.Milliseconds()
}
promptMs := cfg.HeroMeetPromptWindowMs
@ -296,7 +280,7 @@ func (e *Engine) BeginHeroMeetPairLocked(now time.Time, idA, idB int64) (ok bool
}
cfg := tuning.Get()
offlineBudget := randomHeroMeetOfflineDuration(cfg)
offlineBudget := randomDurationBetweenMs(240_000, 360_000)
offlineMs := offlineBudget.Milliseconds()
if offlineMs < 0 {
offlineMs = 0
@ -683,8 +667,7 @@ func (e *Engine) emitHeroMeetAutoLineLocked(lo, hi int64, now time.Time) {
if sh == nil || sh.Hero == nil {
return
}
lineKey := model.HeroMeetAutoPhraseKey(ex.HeroMeetAutoLineIdx)
ex.HeroMeetAutoLineIdx++
lineKey := model.RandomHeroMeetAutoPhraseKey()
if !loOn && !hiOn {
if speakerID == lo {

@ -1827,10 +1827,9 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
if now.Before(deadline) {
break
}
lineIdx := hm.TownVisitLogsEmitted
log(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx),
Code: model.TownVisitRandomPhraseKey(hm.TownVisitNPCType),
},
})
hm.TownVisitLogsEmitted++
@ -2301,7 +2300,14 @@ func ProcessSingleHeroMovementTick(
_ = hm.stepTowardAttractor(now, dtHM)
hm.LastMoveTick = now
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
movePayload := hm.MovePayload(now)
sender.SendToHero(heroID, "hero_move", movePayload)
// Return: partner client must see this hero walking back (same pattern as roadside attractor sync).
if hm.Excursion.Phase == model.ExcursionReturn {
if pid := hm.Excursion.HeroMeetPartnerID; pid != 0 && pid != heroID {
sender.SendToHero(pid, "hero_move", movePayload)
}
}
}
hm.SyncToHero()
return

@ -1,5 +1,7 @@
package model
import "math/rand"
// Phrase keys for adventure_log.event_code / WS adventure_log_line.event.code.
// No human-readable text on the server — only keys and structured args.
@ -52,6 +54,12 @@ var townVisitLineSlugs = map[string][]string{
"rumors_bandits_carts",
"bell_traveler_pack",
"step_back_tally_gold",
"scale_dust_counter",
"rope_coil_trips_you",
"copper_jingles_pouch",
"foreign_coin_bite",
"no_credit_today",
"closing_soon_maybe",
},
"healer": {
"linens_herbs_tent",
@ -60,6 +68,12 @@ var townVisitLineSlugs = map[string][]string{
"tonic_steams_table",
"blessings_salves_bandages",
"lighter_under_canvas",
"needle_flash_quick",
"wash_basin_cloudy",
"herb_bundle_label_faded",
"whisper_count_pulse",
"lint_free_bandage_brag",
"bitter_tea_offer",
},
"quest_giver": {
"scrolls_wax_desk",
@ -68,6 +82,12 @@ var townVisitLineSlugs = map[string][]string{
"draft_parchment_smell",
"squint_spine_legend",
"promise_listen_worth_it",
"seal_crack_important",
"chair_squeak_dramatic",
"window_draft_story",
"stamp_ink_thumb",
"reward_bag_heavier",
"last_hero_failed_joke",
},
"generic": {
"town_noise_blanket",
@ -76,10 +96,16 @@ var townVisitLineSlugs = map[string][]string{
"strap_tighten_pretend",
"dog_boring_sleeps",
"breathe_ready_move_on",
"bell_distant_smith",
"child_chasing_chicken",
"rain_barrel_drip",
"cloak_smell_smoke",
"notice_board_torn",
"two_guards_yawn",
},
}
// TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..5).
// TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..n); prefer TownVisitRandomPhraseKey for timed logs.
func TownVisitPhraseKey(npcType string, lineIdx int) string {
slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType
@ -92,3 +118,17 @@ func TownVisitPhraseKey(npcType string, lineIdx int) string {
}
return "town_visit." + keyType + "." + slugs[lineIdx]
}
// TownVisitRandomPhraseKey picks a random line for npcType (town NPC visit log).
func TownVisitRandomPhraseKey(npcType string) string {
slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType
if !ok {
slugs = townVisitLineSlugs["generic"]
keyType = "generic"
}
if len(slugs) == 0 {
return ""
}
return "town_visit." + keyType + "." + slugs[rand.Intn(len(slugs))]
}

@ -29,3 +29,12 @@ func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) {
t.Fatalf("unknown type should use generic slugs, got %q", k2)
}
}
func TestTownVisitRandomPhraseKeyNonEmpty(t *testing.T) {
for i := 0; i < 20; i++ {
k := TownVisitRandomPhraseKey("merchant")
if k == "" || len(k) < len("town_visit.merchant.") {
t.Fatalf("unexpected key %q", k)
}
}
}

@ -139,7 +139,7 @@ func (h *Hero) LevelUp() bool {
h.MaxHP += hpBase + h.Constitution/6
}
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack++
h.Attack ++
}
if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 {
h.Defense++

@ -1,5 +1,7 @@
package model
import "math/rand"
// HeroMeetAutoLineSlugs are stable ids for auto-dialogue (client localizes hero_meet.auto.<slug>).
var HeroMeetAutoLineSlugs = []string{
"nod_traveler",
@ -14,9 +16,25 @@ var HeroMeetAutoLineSlugs = []string{
"wind_picks_up",
"good_luck_hunt",
"watch_the_brush",
"same_road_twice",
"water_skin_low",
"campfire_smoke_ahead",
"no_coin_no_story",
"armor_pinch_reminder",
"storm_smell_air",
"map_wrong_fold",
"heard_city_bells",
"strap_mended_maybe",
"monster_or_mud",
"share_rations_nod",
"night_cold_early",
"footprints_cross_yours",
"quiet_not_safe",
"merchant_lied_once",
"birds_flew_strange",
}
// HeroMeetAutoPhraseKey returns phrase key e.g. hero_meet.auto.nod_traveler for log / WS.
// HeroMeetAutoPhraseKey returns phrase key by index (legacy tests); prefer RandomHeroMeetAutoPhraseKey for emits.
func HeroMeetAutoPhraseKey(lineIdx int) string {
if len(HeroMeetAutoLineSlugs) == 0 {
return "hero_meet.auto.fallback"
@ -27,3 +45,12 @@ func HeroMeetAutoPhraseKey(lineIdx int) string {
slug := HeroMeetAutoLineSlugs[lineIdx%len(HeroMeetAutoLineSlugs)]
return "hero_meet.auto." + slug
}
// RandomHeroMeetAutoPhraseKey picks a random auto line (offline / scripted hero-meet dialogue).
func RandomHeroMeetAutoPhraseKey() string {
if len(HeroMeetAutoLineSlugs) == 0 {
return "hero_meet.auto.fallback"
}
slug := HeroMeetAutoLineSlugs[rand.Intn(len(HeroMeetAutoLineSlugs))]
return "hero_meet.auto." + slug
}

@ -0,0 +1,12 @@
package model
import "testing"
func TestRandomHeroMeetAutoPhraseKeyNonEmpty(t *testing.T) {
for i := 0; i < 10; i++ {
k := RandomHeroMeetAutoPhraseKey()
if k == "" || len(k) < len("hero_meet.auto.") {
t.Fatalf("unexpected key %q", k)
}
}
}

@ -54,6 +54,21 @@ var RoadsideSlugs = []string{
"smile_nothing_helps",
"tomorrow_walk_tonight_breathe",
"grind_volume_down",
"inventory_full_soul",
"checkpoint_tree_suspicious",
"buff_icon_inner_peace",
"rng_prayer_whisper",
"horse_missing_inventory",
"quest_marker_behind_you",
"save_button_reality",
"lag_spirit_anvil",
"npc_repeat_same_line",
"grass_pixel_perfect",
"boss_music_birdsong",
"loot_greed_shame_cycle",
"hp_bar_poetry_slack",
"respawn_thought_comfort",
"roadside_meta_fourth_wall",
}
// RoadsidePhraseKey returns the full phrase code for a slug suffix.

@ -0,0 +1,283 @@
-- 20 new towns: 5 inner spiral + 15 outer spiral; roads; NPCs (duplicate merchants / dual quest givers where noted); quests; localization keys.
-- Town order in engine remains ORDER BY level_min from DB — existing towns unchanged.
INSERT INTO public.towns (id, name, name_key, biome, world_x, world_y, radius, level_min, level_max, created_at) VALUES
(12, 'Silverstep', 'town.silverstep.v1', 'meadow', 6172.1, 4478.1, 8, 4, 10, now()),
(13, 'Copperfield', 'town.copperfield.v1', 'forest', 4959.5, 5590.9, 9, 6, 12, now()),
(14, 'Ashford', 'town.ashford.v1', 'ruins', 3526.5, 4781.5, 7, 3, 9, now()),
(15, 'Millbrook', 'town.millbrook.v1', 'meadow', 3853.4, 3168.5, 8, 5, 11, now()),
(16, 'Stonebend', 'town.stonebend.v1', 'canyon', 5488.5, 2981, 10, 8, 14, now()),
(17, 'Highspire', 'town.highspire.v1', 'astral', 10992.3, 4509.9, 18, 40, 46, now()),
(18, 'Saltmere', 'town.saltmere.v1', 'swamp', 10330.9, 7001.7, 16, 38, 44, now()),
(19, 'Ironpost', 'town.ironpost.v1', 'canyon', 8713.1, 9009.1, 17, 42, 48, now()),
(20, 'Greyfen', 'town.greyfen.v1', 'swamp', 6418.8, 10184.9, 15, 35, 41, now()),
(21, 'Dunewatch', 'town.dunewatch.v1', 'meadow', 3844.6, 10325.9, 14, 36, 42, now()),
(22, 'Coldbarrow', 'town.coldbarrow.v1', 'ruins', 1435.5, 9407.7, 14, 33, 39, now()),
(23, 'Mistral', 'town.mistral.v1', 'forest', -391.8, 7589, 13, 31, 37, now()),
(24, 'Hollowmere', 'town.hollowmere.v1', 'swamp', -1321.4, 5184.3, 12, 29, 35, now()),
(25, 'Ashfen', 'town.ashfen.v1', 'volcanic', -1192.5, 2609.5, 12, 27, 33, now()),
(26, 'Thornmere', 'town.thornmere.v1', 'forest', -27.5, 309.6, 11, 25, 31, now()),
(27, 'Windgarde', 'town.windgarde.v1', 'meadow', 1972.2, -1317.6, 11, 23, 29, now()),
(28, 'Frosthollow', 'town.frosthollow.v1', 'ruins', 4460.9, -1990.7, 19, 45, 52, now()),
(29, 'Sungrasp', 'town.sungrasp.v1', 'canyon', 7008.2, -1593.4, 20, 47, 53, now()),
(30, 'Glimmerford', 'town.glimmerford.v1', 'meadow', 9173.7, -194.4, 21, 49, 55, now()),
(31, 'Starveil', 'town.starveil.v1', 'astral', 10582.9, 1964.5, 22, 51, 60, now());
-- Buildings: 12 & 20 have two quest_giver halls + two merchants + healer + dec.
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
(49, 12, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(50, 12, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
(51, 12, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(52, 12, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
(53, 12, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
(54, 12, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(55, 12, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
(56, 13, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(57, 13, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(58, 13, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(59, 13, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(60, 13, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(61, 13, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(62, 14, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(63, 14, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(64, 14, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(65, 14, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(66, 14, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(67, 14, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(68, 15, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(69, 15, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(70, 15, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(71, 15, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(72, 15, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(73, 15, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(74, 16, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(75, 16, 'house.merchant', 7.8, -3.5, 'south', 2.5, 2, now()),
(76, 16, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(77, 16, 'house.healer', 3, 8.1, 'south', 2.5, 2, now()),
(78, 16, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(79, 16, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now()),
(80, 17, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(81, 17, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(82, 17, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(83, 17, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(84, 17, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(85, 17, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(86, 18, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
(87, 18, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
(88, 18, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
(89, 18, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
(90, 18, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(91, 18, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
(92, 19, 'house.quest_giver', -7.2, -4, 'south', 2.5, 2, now()),
(93, 19, 'house.merchant', 8.7, -4, 'south', 2.5, 2, now()),
(94, 19, 'house.merchant', -6, 4, 'south', 2.5, 2, now()),
(95, 19, 'house.healer', 3, 8.4, 'south', 2.5, 2, now()),
(96, 19, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(97, 19, 'decoration.signpost', 0, 10.2, 'south', 0.5, 0.5, now()),
(98, 20, 'house.quest_giver', -6.3, -3.5, 'south', 2.5, 2, now()),
(99, 20, 'house.quest_giver', 6.3, -3.5, 'south', 2.5, 2, now()),
(100, 20, 'house.merchant', -5.5, 3.2, 'south', 2.5, 2, now()),
(101, 20, 'house.merchant', 5.5, 3.2, 'south', 2.5, 2, now()),
(102, 20, 'house.healer', 0, 7.8, 'south', 2.5, 2, now()),
(103, 20, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(104, 20, 'decoration.signpost', 0, 9.6, 'south', 0.5, 0.5, now());
INSERT INTO public.town_buildings (id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES
(105, 21, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(106, 21, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(107, 21, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(108, 21, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(109, 21, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(110, 21, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(111, 22, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(112, 22, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(113, 22, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(114, 22, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(115, 22, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(116, 22, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(117, 23, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(118, 23, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(119, 23, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(120, 23, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(121, 23, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(122, 23, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(123, 24, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(124, 24, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(125, 24, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(126, 24, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(127, 24, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(128, 24, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(129, 25, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(130, 25, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(131, 25, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(132, 25, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(133, 25, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(134, 25, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(135, 26, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(136, 26, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(137, 26, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(138, 26, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(139, 26, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(140, 26, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(141, 27, 'house.quest_giver', -4.8, -3.5, 'south', 2.5, 2, now()),
(142, 27, 'house.merchant', 6.3, -3.5, 'south', 2.5, 2, now()),
(143, 27, 'house.merchant', -4.5, 3.5, 'south', 2.5, 2, now()),
(144, 27, 'house.healer', 3, 7.2, 'south', 2.5, 2, now()),
(145, 27, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(146, 27, 'decoration.signpost', 0, 8.4, 'south', 0.5, 0.5, now()),
(147, 28, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(148, 28, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(149, 28, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(150, 28, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(151, 28, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(152, 28, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(153, 29, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(154, 29, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(155, 29, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(156, 29, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(157, 29, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(158, 29, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(159, 30, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(160, 30, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(161, 30, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(162, 30, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(163, 30, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(164, 30, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now()),
(165, 31, 'house.quest_giver', -8.1, -4.5, 'south', 2.5, 2, now()),
(166, 31, 'house.merchant', 9.6, -4.5, 'south', 2.5, 2, now()),
(167, 31, 'house.merchant', -7, 4.5, 'south', 2.5, 2, now()),
(168, 31, 'house.healer', 3, 9.3, 'south', 2.5, 2, now()),
(169, 31, 'decoration.well', 0, 0, 'south', 1.5, 1.5, now()),
(170, 31, 'decoration.signpost', 0, 11.4, 'south', 0.5, 0.5, now());
INSERT INTO public.npcs (id, town_id, name, name_key, type, offset_x, offset_y, created_at, building_id) VALUES
(27, 12, 'Clerk Sera', 'npc.clerk_sera.v1', 'quest_giver', -6.3, -2.3, now(), 49),
(28, 12, 'Notary Bram', 'npc.notary_bram.v1', 'quest_giver', 6.3, -2.3, now(), 50),
(29, 12, 'Copper Nils', 'npc.copper_nils.v1', 'merchant', -5.5, 4.4, now(), 51),
(30, 12, 'Tin Mara', 'npc.tin_mara.v1', 'merchant', 5.5, 4.4, now(), 52),
(31, 12, 'Sister Calm', 'npc.sister_calm.v1', 'healer', 0, 8.7, now(), 53),
(32, 13, 'Foreman Rook', 'npc.foreman_rook.v1', 'quest_giver', -4.8, -2.3, now(), 56),
(33, 13, 'Wire Merchant', 'npc.wire_merchant.v1', 'merchant', 6.3, -2.3, now(), 57),
(34, 13, 'Bolt Jada', 'npc.bolt_jada.v1', 'merchant', -4.5, 4.4, now(), 58),
(35, 13, 'Sage Mottle', 'npc.sage_mottle.v1', 'healer', 3, 8.1, now(), 59),
(36, 14, 'Warden Pike', 'npc.warden_pike.v1', 'quest_giver', -4.8, -2.3, now(), 62),
(37, 14, 'Ash Vendor', 'npc.ash_vendor.v1', 'merchant', 6.3, -2.3, now(), 63),
(38, 14, 'Scrap Yori', 'npc.scrap_yori.v1', 'merchant', -4.5, 4.4, now(), 64),
(39, 14, 'Herb Rill', 'npc.herb_rill.v1', 'healer', 3, 8.1, now(), 65),
(40, 15, 'Miller Tove', 'npc.miller_tove.v1', 'quest_giver', -4.8, -2.3, now(), 68),
(41, 15, 'Grain Peddler', 'npc.grain_peddler.v1', 'merchant', 6.3, -2.3, now(), 69),
(42, 15, 'Sack Ren', 'npc.sack_ren.v1', 'merchant', -4.5, 4.4, now(), 70),
(43, 15, 'Brother Salve', 'npc.brother_salve.v1', 'healer', 3, 8.1, now(), 71),
(44, 16, 'Stone Judge', 'npc.stone_judge.v1', 'quest_giver', -6.3, -2.3, now(), 74),
(45, 16, 'Edge Trader', 'npc.edge_trader.v1', 'merchant', 7.8, -2.3, now(), 75),
(46, 16, 'Crack Merchant', 'npc.crack_merchant.v1', 'merchant', -5.5, 4.4, now(), 76),
(47, 16, 'Sister Flint', 'npc.sister_flint.v1', 'healer', 3, 9.0, now(), 77),
(48, 17, 'Starward Oren', 'npc.starward_oren.v1', 'quest_giver', -8.1, -3.3, now(), 80),
(49, 17, 'Spire Imports', 'npc.spire_imports.v1', 'merchant', 9.6, -3.3, now(), 81),
(50, 17, 'Comet Outfitter', 'npc.comet_outfitter.v1', 'merchant', -7, 5.7, now(), 82),
(51, 17, 'Void Medic', 'npc.void_medic.v1', 'healer', 3, 10.2, now(), 83),
(52, 18, 'Brine Archivist', 'npc.brine_archivist.v1', 'quest_giver', -7.2, -2.8, now(), 86),
(53, 18, 'Salt Broker', 'npc.salt_broker.v1', 'merchant', 8.7, -2.8, now(), 87),
(54, 18, 'Reed Trader', 'npc.reed_trader.v1', 'merchant', -6, 5.2, now(), 88),
(55, 18, 'Mud Healer', 'npc.mud_healer.v1', 'healer', 3, 9.0, now(), 89),
(56, 19, 'Post Warden', 'npc.post_warden.v1', 'quest_giver', -7.2, -2.8, now(), 92),
(57, 19, 'Ironmonger', 'npc.ironmonger.v1', 'merchant', 8.7, -2.8, now(), 93),
(58, 19, 'Rivet Seller', 'npc.rivet_seller.v1', 'merchant', -6, 5.2, now(), 94),
(59, 19, 'Forge Medic', 'npc.forge_medic.v1', 'healer', 3, 9.0, now(), 95),
(60, 20, 'Bog Chronicler', 'npc.bog_chronicler.v1', 'quest_giver', -6.3, -2.3, now(), 98),
(61, 20, 'Fen Notary', 'npc.fen_notary.v1', 'quest_giver', 6.3, -2.3, now(), 99),
(62, 20, 'Mire Merchant', 'npc.mire_merchant.v1', 'merchant', -5.5, 4.4, now(), 100),
(63, 20, 'Reed Coin', 'npc.reed_coin.v1', 'merchant', 5.5, 4.4, now(), 101),
(64, 20, 'Swamp Mender', 'npc.swamp_mender.v1', 'healer', 0, 8.7, now(), 102),
(65, 21, 'Dune Scout', 'npc.dune_scout.v1', 'quest_giver', -4.8, -2.3, now(), 105),
(66, 21, 'Silt Trader', 'npc.silt_trader.v1', 'merchant', 6.3, -2.3, now(), 106),
(67, 21, 'Sand Peddler', 'npc.sand_peddler.v1', 'merchant', -4.5, 4.4, now(), 107),
(68, 21, 'Grit Healer', 'npc.grit_healer.v1', 'healer', 3, 8.1, now(), 108),
(69, 22, 'Barrow Keeper', 'npc.barrow_keeper.v1', 'quest_giver', -4.8, -2.3, now(), 111),
(70, 22, 'Bone Outfitter', 'npc.bone_outfitter.v1', 'merchant', 6.3, -2.3, now(), 112),
(71, 22, 'Cold Peddler', 'npc.cold_peddler.v1', 'merchant', -4.5, 4.4, now(), 113),
(72, 22, 'Shroud Medic', 'npc.shroud_medic.v1', 'healer', 3, 8.1, now(), 114),
(73, 23, 'Mist Ranger', 'npc.mist_ranger.v1', 'quest_giver', -4.8, -2.3, now(), 117),
(74, 23, 'Fog Trader', 'npc.fog_trader.v1', 'merchant', 6.3, -2.3, now(), 118),
(75, 23, 'Dew Merchant', 'npc.dew_merchant.v1', 'merchant', -4.5, 4.4, now(), 119),
(76, 23, 'Vapor Healer', 'npc.vapor_healer.v1', 'healer', 3, 8.1, now(), 120),
(77, 24, 'Hollow Scribe', 'npc.hollow_scribe.v1', 'quest_giver', -4.8, -2.3, now(), 123),
(78, 24, 'Mer Imports', 'npc.mer_imports.v1', 'merchant', 6.3, -2.3, now(), 124),
(79, 24, 'Rot Trader', 'npc.rot_trader.v1', 'merchant', -4.5, 4.4, now(), 125),
(80, 24, 'Bog Medic', 'npc.bog_medic.v1', 'healer', 3, 8.1, now(), 126),
(81, 25, 'Ash Herald', 'npc.herald_ash.v1', 'quest_giver', -4.8, -2.3, now(), 129),
(82, 25, 'Cinder Seller', 'npc.cinder_seller.v1', 'merchant', 6.3, -2.3, now(), 130),
(83, 25, 'Ember Peddler', 'npc.ember_peddler.v1', 'merchant', -4.5, 4.4, now(), 131),
(84, 25, 'Ash Healer', 'npc.ash_healer.v1', 'healer', 3, 8.1, now(), 132),
(85, 26, 'Thorn Watcher', 'npc.thorn_watcher.v1', 'quest_giver', -4.8, -2.3, now(), 135),
(86, 26, 'Briar Trader', 'npc.briar_trader.v1', 'merchant', 6.3, -2.3, now(), 136),
(87, 26, 'Root Seller', 'npc.root_seller.v1', 'merchant', -4.5, 4.4, now(), 137),
(88, 26, 'Leaf Medic', 'npc.leaf_medic.v1', 'healer', 3, 8.1, now(), 138),
(89, 27, 'Gale Factor', 'npc.gale_factor.v1', 'quest_giver', -4.8, -2.3, now(), 141),
(90, 27, 'Wind Outfitter', 'npc.wind_outfitter.v1', 'merchant', 6.3, -2.3, now(), 142),
(91, 27, 'Gust Peddler', 'npc.gust_peddler.v1', 'merchant', -4.5, 4.4, now(), 143),
(92, 27, 'Breeze Healer', 'npc.breeze_healer.v1', 'healer', 3, 8.1, now(), 144),
(93, 28, 'Frost Archivist', 'npc.frost_archivist.v1', 'quest_giver', -8.1, -3.3, now(), 147),
(94, 28, 'Rime Trader', 'npc.rime_trader.v1', 'merchant', 9.6, -3.3, now(), 148),
(95, 28, 'Hoarfrost Seller', 'npc.hoarfrost_seller.v1', 'merchant', -7, 5.7, now(), 149),
(96, 28, 'Ice Medic', 'npc.ice_medic.v1', 'healer', 3, 10.2, now(), 150),
(97, 29, 'Sun Warden', 'npc.sun_warden.v1', 'quest_giver', -8.1, -3.3, now(), 153),
(98, 29, 'Cliff Merchant', 'npc.cliff_merchant.v1', 'merchant', 9.6, -3.3, now(), 154),
(99, 29, 'Crag Peddler', 'npc.crag_peddler.v1', 'merchant', -7, 5.7, now(), 155),
(100, 29, 'Dust Healer', 'npc.dust_healer.v1', 'healer', 3, 10.2, now(), 156),
(101, 30, 'Ford Marshal', 'npc.ford_marshal.v1', 'quest_giver', -8.1, -3.3, now(), 159),
(102, 30, 'River Trader', 'npc.river_trader.v1', 'merchant', 9.6, -3.3, now(), 160),
(103, 30, 'Bridge Seller', 'npc.bridge_seller.v1', 'merchant', -7, 5.7, now(), 161),
(104, 30, 'Stream Medic', 'npc.stream_medic.v1', 'healer', 3, 10.2, now(), 162),
(105, 31, 'Veil Seer', 'npc.veil_seer.v1', 'quest_giver', -8.1, -3.3, now(), 165),
(106, 31, 'Star Trader', 'npc.star_trader.v1', 'merchant', 9.6, -3.3, now(), 166),
(107, 31, 'Nebula Peddler', 'npc.nebula_peddler.v1', 'merchant', -7, 5.7, now(), 167),
(108, 31, 'Veil Mender', 'npc.veil_mender_starveil.v1', 'healer', 3, 10.2, now(), 168);
-- Directed roads (pairwise both ways). Distance is recomputed at load from waypoints.
INSERT INTO public.roads (id, from_town_id, to_town_id, distance) VALUES
(51, 12, 4, 1000), (52, 4, 12, 1000), (53, 12, 10, 1000), (54, 10, 12, 1000), (55, 12, 13, 1000), (56, 13, 12, 1000),
(57, 13, 11, 1000), (58, 11, 13, 1000), (59, 13, 5, 1000), (60, 5, 13, 1000), (61, 13, 14, 1000), (62, 14, 13, 1000),
(63, 14, 5, 1000), (64, 5, 14, 1000), (65, 14, 6, 1000), (66, 6, 14, 1000), (67, 14, 15, 1000), (68, 15, 14, 1000),
(69, 15, 5, 1000), (70, 5, 15, 1000), (71, 15, 16, 1000), (72, 16, 15, 1000), (73, 16, 1, 1000), (74, 1, 16, 1000),
(75, 16, 8, 1000), (76, 8, 16, 1000), (77, 16, 12, 1000), (78, 12, 16, 1000),
(79, 17, 18, 1000), (80, 18, 17, 1000), (81, 18, 19, 1000), (82, 19, 18, 1000), (83, 19, 20, 1000), (84, 20, 19, 1000),
(85, 20, 21, 1000), (86, 21, 20, 1000), (87, 21, 22, 1000), (88, 22, 21, 1000), (89, 22, 23, 1000), (90, 23, 22, 1000),
(91, 23, 24, 1000), (92, 24, 23, 1000), (93, 24, 25, 1000), (94, 25, 24, 1000), (95, 25, 26, 1000), (96, 26, 25, 1000),
(97, 26, 27, 1000), (98, 27, 26, 1000), (99, 27, 28, 1000), (100, 28, 27, 1000), (101, 28, 29, 1000), (102, 29, 28, 1000),
(103, 29, 30, 1000), (104, 30, 29, 1000), (105, 30, 31, 1000), (106, 31, 30, 1000), (107, 31, 17, 1000), (108, 17, 31, 1000),
(109, 17, 3, 1000), (110, 3, 17, 1000), (111, 18, 10, 1000), (112, 10, 18, 1000), (113, 19, 4, 1000), (114, 4, 19, 1000),
(115, 20, 11, 1000), (116, 11, 20, 1000), (117, 21, 11, 1000), (118, 11, 21, 1000), (119, 22, 5, 1000), (120, 5, 22, 1000),
(121, 23, 6, 1000), (122, 6, 23, 1000), (123, 24, 6, 1000), (124, 6, 24, 1000), (125, 25, 7, 1000), (126, 7, 25, 1000),
(127, 26, 7, 1000), (128, 7, 26, 1000), (129, 27, 7, 1000), (130, 7, 27, 1000), (131, 28, 1, 1000), (132, 1, 28, 1000),
(133, 29, 1, 1000), (134, 1, 29, 1000), (135, 30, 1, 1000), (136, 1, 30, 1000), (137, 31, 2, 1000), (138, 2, 31, 1000);
INSERT INTO public.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
(27, 'quest.silverstep_bandits.v1', 'Bandit Echo', 'Thin bandit packs along the inner roads.', 'kill_count', 5, NULL, 'bandit', NULL, 0, 4, 10, 90, 48, 0),
(28, 'quest.silverstep_visit_mossharbor.v1', 'Letter to Mossharbor', 'Carry a sealed note to Harbor-ward Lissa.', 'visit_town', 1, NULL, NULL, 8, 0, 4, 10, 55, 30, 0),
(32, 'quest.copperfield_wolves.v1', 'Copperfield Wolves', 'Wolves circle the smelting sheds.', 'kill_count', 6, NULL, 'wolf', NULL, 0, 6, 12, 120, 62, 0),
(36, 'quest.ashford_skeletons.v1', 'Ashford Bones', 'Risen bones worry the ruins lane.', 'kill_count', 7, NULL, 'skeleton', NULL, 0, 3, 9, 85, 44, 0),
(40, 'quest.millbrook_boars.v1', 'Millbrook Boars', 'Boars ruin the grain path.', 'kill_count', 6, NULL, 'boar', NULL, 0, 5, 11, 100, 52, 0),
(44, 'quest.stonebend_orcs.v1', 'Stonebend Orcs', 'Orc scouts press the canyon shelf.', 'kill_count', 8, NULL, 'orc', NULL, 0, 8, 14, 140, 75, 1),
(48, 'quest.highspire_shades.v1', 'Shade at the Spire', 'Shades cling to the high astral road.', 'kill_count', 6, NULL, 'shade', NULL, 0, 40, 46, 520, 300, 2),
(52, 'quest.saltmere_spiders.v1', 'Saltmere Silk', 'Spiders infest the brine posts.', 'kill_count', 8, NULL, 'spider', NULL, 0, 38, 44, 480, 280, 1),
(56, 'quest.ironpost_golems.v1', 'Ironpost Sentinels', 'Golems block the iron road.', 'kill_count', 5, NULL, 'golem', NULL, 0, 42, 48, 560, 320, 2),
(60, 'quest.greyfen_harpies.v1', 'Greyfen Harpies', 'Harpies pick at the fen docks.', 'kill_count', 7, NULL, 'harpy', NULL, 0, 35, 41, 420, 240, 1),
(61, 'quest.greyfen_visit_duskwatch.v1', 'Warning to Duskwatch', 'Bring tidings to Sister Morah.', 'visit_town', 1, NULL, NULL, 11, 0, 35, 41, 200, 110, 0),
(65, 'quest.dunewatch_zombies.v1', 'Dune Dead', 'Zombies wander the silt flats.', 'kill_count', 10, NULL, 'zombie', NULL, 0, 36, 42, 440, 250, 1),
(69, 'quest.coldbarrow_wraiths.v1', 'Coldbarrow Wraiths', 'Wraiths drift between the barrows.', 'kill_count', 8, NULL, 'wraith', NULL, 0, 33, 39, 400, 230, 1),
(73, 'quest.mistral_cultists.v1', 'Mistral Cultists', 'Cultists chant in the fog line.', 'kill_count', 9, NULL, 'cultist', NULL, 0, 31, 37, 380, 220, 1),
(77, 'quest.hollowmere_treants.v1', 'Hollowmere Roots', 'Treants root in the hollow mere.', 'kill_count', 4, NULL, 'treant', NULL, 0, 29, 35, 360, 210, 1),
(81, 'quest.ashfen_demons.v1', 'Ashfen Embers', 'Demons leave cinders on the ash fen.', 'kill_count', 5, NULL, 'demon', NULL, 0, 27, 33, 340, 200, 1),
(85, 'quest.thornmere_lizards.v1', 'Thornmere Scalebacks', 'Battle lizards bask by the thorns.', 'kill_count', 8, NULL, 'battle_lizard', NULL, 0, 25, 31, 320, 190, 1),
(89, 'quest.windgarde_visit_willowdale.v1', 'Parcel for Willowdale', 'Deliver a parcel to Elder Maren.', 'visit_town', 1, NULL, NULL, 1, 0, 23, 29, 150, 85, 0),
(93, 'quest.frosthollow_titans.v1', 'Frost Titan Steps', 'Titans loom past the frost hollow.', 'kill_count', 4, NULL, 'titan', NULL, 0, 45, 52, 640, 380, 2),
(97, 'quest.sungrasp_wyverns.v1', 'Sungrasp Wyverns', 'Wyverns circle the sun cliffs.', 'kill_count', 6, NULL, 'wyvern', NULL, 0, 47, 53, 680, 400, 2),
(101, 'quest.glimmerford_manticores.v1', 'Glimmerford Alphas', 'Manticores claim the ford approaches.', 'kill_count', 5, NULL, 'manticore', NULL, 0, 49, 55, 720, 420, 2),
(105, 'quest.starveil_wardens.v1', 'Veil Wardens', 'Forest wardens dispute the star road.', 'kill_count', 3, NULL, 'forest_warden', NULL, 0, 51, 60, 800, 480, 3);
SELECT pg_catalog.setval('public.towns_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.towns), true);
SELECT pg_catalog.setval('public.town_buildings_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.town_buildings), true);
SELECT pg_catalog.setval('public.npcs_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.npcs), true);
SELECT pg_catalog.setval('public.roads_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.roads), true);
SELECT pg_catalog.setval('public.quests_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.quests), true);

@ -0,0 +1,29 @@
-- Scale world positions so every road segment (straight line between town centers)
-- is at least (1.2 world_units/sec) * (1.5 * 3600 sec) = 6480 units.
-- Method: uniform scale from centroid of towns 1..31; factor = 6480 / min_edge
-- where min_edge was ~870.54 (towns 82) before this migration.
-- Server recomputes road polylines and Distance in LoadRoadGraph from town coords.
-- Centroid and scale (derived from pre-migration layout including 000030 towns).
-- cx, cy = AVG(world_x), AVG(world_y) over ids 1..31 before scale.
DO $$
DECLARE
cx double precision := 5365.609677419355;
cy double precision := 4259.612903225806;
s double precision := 6480.0 / 870.5435801842434;
BEGIN
UPDATE public.towns
SET world_x = cx + (world_x - cx) * s,
world_y = cy + (world_y - cy) * s
WHERE id BETWEEN 1 AND 31;
UPDATE public.heroes
SET position_x = cx + (position_x - cx) * s,
position_y = cy + (position_y - cy) * s;
END $$;
-- Stored road.distance is overwritten at runtime; scale placeholders for any SQL/reporting.
UPDATE public.roads SET distance = distance * (6480.0 / 870.5435801842434);
-- Legacy table not read by Go server; remove stale geometry so DB matches new world.
TRUNCATE TABLE public.road_waypoints;

@ -20,6 +20,26 @@ export const TOWN_ID_TO_NAME_KEY: Record<number, string> = {
9: 'town.emberwell.v1',
10: 'town.frostmark.v1',
11: 'town.duskwatch.v1',
12: 'town.silverstep.v1',
13: 'town.copperfield.v1',
14: 'town.ashford.v1',
15: 'town.millbrook.v1',
16: 'town.stonebend.v1',
17: 'town.highspire.v1',
18: 'town.saltmere.v1',
19: 'town.ironpost.v1',
20: 'town.greyfen.v1',
21: 'town.dunewatch.v1',
22: 'town.coldbarrow.v1',
23: 'town.mistral.v1',
24: 'town.hollowmere.v1',
25: 'town.ashfen.v1',
26: 'town.thornmere.v1',
27: 'town.windgarde.v1',
28: 'town.frosthollow.v1',
29: 'town.sungrasp.v1',
30: 'town.glimmerford.v1',
31: 'town.starveil.v1',
};
/** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */
@ -46,6 +66,26 @@ const TOWNS: Record<string, Bilingual> = {
'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' },
'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' },
'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' },
'town.silverstep.v1': { en: 'Silverstep', ru: 'Сильверстеп' },
'town.copperfield.v1': { en: 'Copperfield', ru: 'Копперфилд' },
'town.ashford.v1': { en: 'Ashford', ru: 'Эшфорд' },
'town.millbrook.v1': { en: 'Millbrook', ru: 'Милбрук' },
'town.stonebend.v1': { en: 'Stonebend', ru: 'Стоунбенд' },
'town.highspire.v1': { en: 'Highspire', ru: 'Хайспайр' },
'town.saltmere.v1': { en: 'Saltmere', ru: 'Солтмиер' },
'town.ironpost.v1': { en: 'Ironpost', ru: 'Айронпост' },
'town.greyfen.v1': { en: 'Greyfen', ru: 'Грейфен' },
'town.dunewatch.v1': { en: 'Dunewatch', ru: 'Дьюнвотч' },
'town.coldbarrow.v1': { en: 'Coldbarrow', ru: 'Колдбарроу' },
'town.mistral.v1': { en: 'Mistral', ru: 'Мистраль' },
'town.hollowmere.v1': { en: 'Hollowmere', ru: 'Холлоумир' },
'town.ashfen.v1': { en: 'Ashfen', ru: 'Эшфен' },
'town.thornmere.v1': { en: 'Thornmere', ru: 'Торнмир' },
'town.windgarde.v1': { en: 'Windgarde', ru: 'Виндгард' },
'town.frosthollow.v1': { en: 'Frosthollow', ru: 'Фростхоллоу' },
'town.sungrasp.v1': { en: 'Sungrasp', ru: 'Санграсп' },
'town.glimmerford.v1': { en: 'Glimmerford', ru: 'Глиммерфорд' },
'town.starveil.v1': { en: 'Starveil', ru: 'Старвейл' },
};
const NPCS: Record<string, Bilingual> = {
@ -58,6 +98,88 @@ const NPCS: Record<string, Bilingual> = {
'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' },
'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' },
[WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' },
'npc.clerk_sera.v1': { en: 'Clerk Sera', ru: 'Клер Сера' },
'npc.notary_bram.v1': { en: 'Notary Bram', ru: 'Нотариус Брам' },
'npc.copper_nils.v1': { en: 'Copper Nils', ru: 'Коппер Нилс' },
'npc.tin_mara.v1': { en: 'Tin Mara', ru: 'Тин Мара' },
'npc.sister_calm.v1': { en: 'Sister Calm', ru: 'Сестра Калм' },
'npc.foreman_rook.v1': { en: 'Foreman Rook', ru: 'Форман Рук' },
'npc.wire_merchant.v1': { en: 'Wire Merchant', ru: 'Торговец проволокой' },
'npc.bolt_jada.v1': { en: 'Bolt Jada', ru: 'Болт Джада' },
'npc.sage_mottle.v1': { en: 'Sage Mottle', ru: 'Сейдж Моттл' },
'npc.warden_pike.v1': { en: 'Warden Pike', ru: 'Варден Пайк' },
'npc.ash_vendor.v1': { en: 'Ash Vendor', ru: 'Торговец золой' },
'npc.scrap_yori.v1': { en: 'Scrap Yori', ru: 'Скрап Йори' },
'npc.herb_rill.v1': { en: 'Herb Rill', ru: 'Херб Рилл' },
'npc.miller_tove.v1': { en: 'Miller Tove', ru: 'Миллер Тов' },
'npc.grain_peddler.v1': { en: 'Grain Peddler', ru: 'Зерновой бродяга' },
'npc.sack_ren.v1': { en: 'Sack Ren', ru: 'Сак Рен' },
'npc.brother_salve.v1': { en: 'Brother Salve', ru: 'Брат Сальв' },
'npc.stone_judge.v1': { en: 'Stone Judge', ru: 'Каменный судья' },
'npc.edge_trader.v1': { en: 'Edge Trader', ru: 'Торговец с краю' },
'npc.crack_merchant.v1': { en: 'Crack Merchant', ru: 'Торговец из трещины' },
'npc.sister_flint.v1': { en: 'Sister Flint', ru: 'Сестра Флинт' },
'npc.starward_oren.v1': { en: 'Starward Oren', ru: 'Старворд Орен' },
'npc.spire_imports.v1': { en: 'Spire Imports', ru: 'Спайр Импортс' },
'npc.comet_outfitter.v1': { en: 'Comet Outfitter', ru: 'Комет Аутфиттер' },
'npc.void_medic.v1': { en: 'Void Medic', ru: 'Медик пустоты' },
'npc.brine_archivist.v1': { en: 'Brine Archivist', ru: 'Архивариус рассола' },
'npc.salt_broker.v1': { en: 'Salt Broker', ru: 'Солёный брокер' },
'npc.reed_trader.v1': { en: 'Reed Trader', ru: 'Торговец тростником' },
'npc.mud_healer.v1': { en: 'Mud Healer', ru: 'Грязевой лекарь' },
'npc.post_warden.v1': { en: 'Post Warden', ru: 'Страж поста' },
'npc.ironmonger.v1': { en: 'Ironmonger', ru: 'Железный торговец' },
'npc.rivet_seller.v1': { en: 'Rivet Seller', ru: 'Продавец заклёпок' },
'npc.forge_medic.v1': { en: 'Forge Medic', ru: 'Кузнечный медик' },
'npc.bog_chronicler.v1': { en: 'Bog Chronicler', ru: 'Хронист болота' },
'npc.fen_notary.v1': { en: 'Fen Notary', ru: 'Нотариус топи' },
'npc.mire_merchant.v1': { en: 'Mire Merchant', ru: 'Торговец трясиной' },
'npc.reed_coin.v1': { en: 'Reed Coin', ru: 'Рид Коин' },
'npc.swamp_mender.v1': { en: 'Swamp Mender', ru: 'Болотный латальщик' },
'npc.dune_scout.v1': { en: 'Dune Scout', ru: 'Разведчик дюн' },
'npc.silt_trader.v1': { en: 'Silt Trader', ru: 'Торговец илом' },
'npc.sand_peddler.v1': { en: 'Sand Peddler', ru: 'Песочный бродяга' },
'npc.grit_healer.v1': { en: 'Grit Healer', ru: 'Грит‑лекарь' },
'npc.barrow_keeper.v1': { en: 'Barrow Keeper', ru: 'Хранитель курганов' },
'npc.bone_outfitter.v1': { en: 'Bone Outfitter', ru: 'Костяной снаряженец' },
'npc.cold_peddler.v1': { en: 'Cold Peddler', ru: 'Холодный бродяга' },
'npc.shroud_medic.v1': { en: 'Shroud Medic', ru: 'Медик покрова' },
'npc.mist_ranger.v1': { en: 'Mist Ranger', ru: 'Рейнджер тумана' },
'npc.fog_trader.v1': { en: 'Fog Trader', ru: 'Торговец туманом' },
'npc.dew_merchant.v1': { en: 'Dew Merchant', ru: 'Торговец росой' },
'npc.vapor_healer.v1': { en: 'Vapor Healer', ru: 'Лекарь пара' },
'npc.hollow_scribe.v1': { en: 'Hollow Scribe', ru: 'Писарь пустоты' },
'npc.mer_imports.v1': { en: 'Mer Imports', ru: 'Мер Импортс' },
'npc.rot_trader.v1': { en: 'Rot Trader', ru: 'Торговец гнилью' },
'npc.bog_medic.v1': { en: 'Bog Medic', ru: 'Болотный медик' },
'npc.herald_ash.v1': { en: 'Ash Herald', ru: 'Глашатай пепла' },
'npc.cinder_seller.v1': { en: 'Cinder Seller', ru: 'Продавец золы' },
'npc.ember_peddler.v1': { en: 'Ember Peddler', ru: 'Угольный бродяга' },
'npc.ash_healer.v1': { en: 'Ash Healer', ru: 'Пепельный лекарь' },
'npc.thorn_watcher.v1': { en: 'Thorn Watcher', ru: 'Дозорный шипов' },
'npc.briar_trader.v1': { en: 'Briar Trader', ru: 'Торговец шипами' },
'npc.root_seller.v1': { en: 'Root Seller', ru: 'Продавец корней' },
'npc.leaf_medic.v1': { en: 'Leaf Medic', ru: 'Лиственный медик' },
'npc.gale_factor.v1': { en: 'Gale Factor', ru: 'Фактор шторма' },
'npc.wind_outfitter.v1': { en: 'Wind Outfitter', ru: 'Ветряной снаряженец' },
'npc.gust_peddler.v1': { en: 'Gust Peddler', ru: 'Порывистый бродяга' },
'npc.breeze_healer.v1': { en: 'Breeze Healer', ru: 'Лекарь бриза' },
'npc.frost_archivist.v1': { en: 'Frost Archivist', ru: 'Морозный архивариус' },
'npc.rime_trader.v1': { en: 'Rime Trader', ru: 'Торговец инеем' },
'npc.hoarfrost_seller.v1': { en: 'Hoarfrost Seller', ru: 'Продавец инея' },
'npc.ice_medic.v1': { en: 'Ice Medic', ru: 'Лёд‑медик' },
'npc.sun_warden.v1': { en: 'Sun Warden', ru: 'Страж солнца' },
'npc.cliff_merchant.v1': { en: 'Cliff Merchant', ru: 'Утёсный торговец' },
'npc.crag_peddler.v1': { en: 'Crag Peddler', ru: 'Краг‑бродяга' },
'npc.dust_healer.v1': { en: 'Dust Healer', ru: 'Пыльный лекарь' },
'npc.ford_marshal.v1': { en: 'Ford Marshal', ru: 'Маршал брода' },
'npc.river_trader.v1': { en: 'River Trader', ru: 'Речной торговец' },
'npc.bridge_seller.v1': { en: 'Bridge Seller', ru: 'Продавец мостов' },
'npc.stream_medic.v1': { en: 'Stream Medic', ru: 'Ручьевой медик' },
'npc.veil_seer.v1': { en: 'Veil Seer', ru: 'Видящая завесы' },
'npc.star_trader.v1': { en: 'Star Trader', ru: 'Звёздный торговец' },
'npc.nebula_peddler.v1': { en: 'Nebula Peddler', ru: 'Туманность‑бродяга' },
'npc.veil_mender_starveil.v1': { en: 'Veil Mender', ru: 'Латальщик завесы' },
};
const DIALOGUES: Record<string, Bilingual> = {

@ -239,6 +239,22 @@ adventure_log:
hero_meet.auto.wind_picks_up: Wind's picking up.
hero_meet.auto.good_luck_hunt: Good luck out there.
hero_meet.auto.watch_the_brush: Watch the treeline.
hero_meet.auto.same_road_twice: Feels like we walked this bend twice.
hero_meet.auto.water_skin_low: Water's low in the skin — next well matters.
hero_meet.auto.campfire_smoke_ahead: Smoke ahead — maybe a safe fire.
hero_meet.auto.no_coin_no_story: No coin, no story — that's the rule.
hero_meet.auto.armor_pinch_reminder: Armor pinches; you tighten it anyway.
hero_meet.auto.storm_smell_air: Storm smell on the air — or just your imagination.
hero_meet.auto.map_wrong_fold: Your map has the wrong fold; you pretend it's fine.
hero_meet.auto.heard_city_bells: Thought I heard city bells — probably birds.
hero_meet.auto.strap_mended_maybe: Strap's held so far; 'mended' is a strong word.
hero_meet.auto.monster_or_mud: Could be a monster — could be mud. Same heartbeat.
hero_meet.auto.share_rations_nod: If you share rations, we nod and call it peace.
hero_meet.auto.night_cold_early: Night feels early today; the road disagrees.
hero_meet.auto.footprints_cross_yours: Footprints cross yours — none of your business.
hero_meet.auto.quiet_not_safe: Quiet doesn't mean safe; it means listening.
hero_meet.auto.merchant_lied_once: A merchant lied to me once. I bought soup anyway.
hero_meet.auto.birds_flew_strange: Birds flew strange yesterday. Today they're normal. Suspicious.
achievements:
first_blood: First Blood
@ -526,6 +542,21 @@ roadside:
smile_nothing_helps: You smile at nothing in particular. It helps.
tomorrow_walk_tonight_breathe: Tomorrow you'll walk again. Tonight you just breathe.
grind_volume_down: You admit the grind is loud, then turn the volume down.
inventory_full_soul: If your soul had inventory slots, regret would be legendary.
checkpoint_tree_suspicious: That tree looks like a checkpoint. It refuses to save.
buff_icon_inner_peace: You search for a buff icon for inner peace. Not lootable.
rng_prayer_whisper: You whisper a small prayer to RNG. It answers with wind.
horse_missing_inventory: Your imaginary horse is missing from inventory. Tragic.
quest_marker_behind_you: The quest marker is probably behind you. Classic.
save_button_reality: You wish reality had a save button. It has dirt instead.
lag_spirit_anvil: Your spirit lags one beat behind your body. Anvil timing.
npc_repeat_same_line: You suspect NPCs rehearse the same line in every timeline.
grass_pixel_perfect: The grass is insultingly pretty. Pixel-perfect shame.
boss_music_birdsong: No boss music — only birds. Somehow worse.
loot_greed_shame_cycle: Loot thought, then greed, then shame. Full combo.
hp_bar_poetry_slack: Your HP bar is poetry written in slack.
respawn_thought_comfort: Respawn isn't real, but the thought is warm.
roadside_meta_fourth_wall: Even the roadside thinks the fourth wall is drafty.
town_npc_visit:
merchant:
@ -535,6 +566,12 @@ town_npc_visit:
rumors_bandits_carts: You swap rumors about bandits and broken cart wheels.
bell_traveler_pack: A bell tinkles as another traveler shoulders their pack.
step_back_tally_gold: You step back, mentally tallying what you can afford.
scale_dust_counter: Dust on the scales makes every coin an argument.
rope_coil_trips_you: A coil of rope tries to trip you — friendly sabotage.
copper_jingles_pouch: Copper jingles like it owns the pouch.
foreign_coin_bite: You test a foreign coin with your teeth. Old habit.
no_credit_today: '''No credit today'' is carved into the counter like scripture.'
closing_soon_maybe: They mutter 'closing soon' with zero conviction.
healer:
linens_herbs_tent: Clean linens and sharp herbs fill the small tent.
professional_frown_onceover: The healer looks you over with a professional frown.
@ -542,6 +579,12 @@ town_npc_visit:
tonic_steams_table: A tonic steams on the side table; you hope it is not meant for you.
blessings_salves_bandages: They mutter blessings while sorting salves and bandages.
lighter_under_canvas: You feel oddly lighter just standing under the canvas.
needle_flash_quick: A needle flashes; you look away like bravery is optional.
wash_basin_cloudy: The wash basin is cloudy — honesty in plumbing.
herb_bundle_label_faded: Herb bundles wear labels faded into myth.
whisper_count_pulse: They whisper numbers that might be pulse or price.
lint_free_bandage_brag: They brag about lint-free bandages. You almost believe.
bitter_tea_offer: Bitter tea is offered as medicine or punishment. Both.
quest_giver:
scrolls_wax_desk: Scrolls and wax seals clutter the quest givers desk.
ink_stained_map_tap: They tap a map with an ink-stained finger.
@ -549,6 +592,12 @@ town_npc_visit:
draft_parchment_smell: A draft carries the smell of old parchment.
squint_spine_legend: They squint as if measuring your spine against a legend.
promise_listen_worth_it: You promise to listen; they promise it will be worth it.
seal_crack_important: A seal cracks; somehow that means 'important.'
chair_squeak_dramatic: The chair squeaks on cue — unpaid sound designer.
window_draft_story: A draft through the window carries someone else's story.
stamp_ink_thumb: Ink stains their thumb like a second seal.
reward_bag_heavier: The reward bag looks heavier than your conscience.
last_hero_failed_joke: They joke that the last hero failed upward. Ha.
generic:
town_noise_blanket: You pause; the town noise folds around you like a blanket.
grain_prices_argument: Someone nearby argues about grain prices in good humor.
@ -556,3 +605,9 @@ town_npc_visit:
strap_tighten_pretend: You tighten a strap and pretend you meant to stop here.
dog_boring_sleeps: A dog watches you, decides you are boring, and sleeps.
breathe_ready_move_on: You breathe out, ready to move on when the moment feels right.
bell_distant_smith: A distant smith's bell argues with the noon heat.
child_chasing_chicken: A child chases a chicken; civilization holds.
rain_barrel_drip: Rain drips from a barrel like slow percussion.
cloak_smell_smoke: Your cloak smells faintly of smoke and older roads.
notice_board_torn: The notice board is half torn — optimism with teeth.
two_guards_yawn: Two guards yawn in unison; discipline, but sleepy.

@ -239,6 +239,22 @@ adventure_log:
hero_meet.auto.wind_picks_up: Ветер усиливается.
hero_meet.auto.good_luck_hunt: Удачи.
hero_meet.auto.watch_the_brush: Смотри под кусты.
hero_meet.auto.same_road_twice: Кажется, этот поворот мы уже проходили.
hero_meet.auto.water_skin_low: В бурдюке мало воды — следующий колодец важен.
hero_meet.auto.campfire_smoke_ahead: Впереди дым — может, безопасный костёр.
hero_meet.auto.no_coin_no_story: Нет монеты — нет истории, так у них заведено.
hero_meet.auto.armor_pinch_reminder: Доспех жмёт — всё равно подтягиваешь ремень.
hero_meet.auto.storm_smell_air: Пахнет грозой — или это воображение.
hero_meet.auto.map_wrong_fold: Карта сложена не той стороной — делаешь вид, что так надо.
hero_meet.auto.heard_city_bells: Показалось, слышны городские колокола — наверное птицы.
hero_meet.auto.strap_mended_maybe: Ремень пока держится; «починен» — громко сказано.
hero_meet.auto.monster_or_mud: Может монстр — может грязь. Пульс одинаковый.
hero_meet.auto.share_rations_nod: Поделишься пайком — кивнём и назовём это миром.
hero_meet.auto.night_cold_early: Ночь сегодня ранняя; дорога с этим не согласна.
hero_meet.auto.footprints_cross_yours: Чужие следы пересекают твои — не твоё дело.
hero_meet.auto.quiet_not_safe: Тихо — не значит безопасно; значит надо слушать.
hero_meet.auto.merchant_lied_once: Торговец однажды соврал. Ты всё равно купил похлёбку.
hero_meet.auto.birds_flew_strange: Вчера птицы летели странно. Сегодня нормально. Подозрительно.
achievements:
first_blood: 'Первая кровь'
@ -526,6 +542,21 @@ roadside:
smile_nothing_helps: 'Ты улыбаешься ни о чём. Помогает.'
tomorrow_walk_tonight_breathe: 'Завтра снова пойдёшь. Сегодня просто дышишь.'
grind_volume_down: 'Ты признаёшь, что гринд громкий, и приглушаешь громкость.'
inventory_full_soul: 'Если бы у души были слоты, сожаление было бы легендарным лутом.'
checkpoint_tree_suspicious: 'Это дерево похоже на чекпоинт. Оно отказывается сохранять.'
buff_icon_inner_peace: 'Ищешь иконку баффа «внутренний покой». Не дропается.'
rng_prayer_whisper: 'Шепчешь молитву RNG. В ответ — ветер.'
horse_missing_inventory: 'Воображаемой лошади нет в инвентаре. Трагедия.'
quest_marker_behind_you: 'Метка квеста, наверное, сзади. Классика.'
save_button_reality: 'Хочется кнопки сохранения у реальности. Есть только грязь.'
lag_spirit_anvil: 'Дух отстаёт на удар от тела. Тайминг на наковальне.'
npc_repeat_same_line: 'Кажется, NPC репетируют ту же реплику во всех мирах.'
grass_pixel_perfect: 'Трава обидно красива. Пиксельное совершенство.'
boss_music_birdsong: 'Нет босс-музыки — только птицы. Как-то хуже.'
loot_greed_shame_cycle: 'Мысль о луте, жадность, стыд. Полное комбо.'
hp_bar_poetry_slack: 'Полоска HP — поэзия, написанная вялостью.'
respawn_thought_comfort: 'Респавна нет, но мысль о нём греет.'
roadside_meta_fourth_wall: 'Даже обочина считает, что четвёртая стена продувается.'
town_npc_visit:
merchant:
@ -535,6 +566,12 @@ town_npc_visit:
rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.'
bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.'
step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.'
scale_dust_counter: 'Пыль на весах превращает каждую монету в спор.'
rope_coil_trips_you: 'Клубок верёвки пытается подставить подножку — дружеская диверсия.'
copper_jingles_pouch: 'Медь звенит так, будто кошелёк её не принадлежит.'
foreign_coin_bite: 'Проверяешь чужую монету зубами. Старая привычка.'
no_credit_today: '''Сегодня без кредита'' вырезано на прилавке как заповедь.'
closing_soon_maybe: 'Бормочут «скоро закрываем» без единой искренности.'
healer:
linens_herbs_tent: 'В палатке пахнет чистым бельём и резкими травами.'
professional_frown_onceover: 'Целитель окинул тебя взглядом с профессиональным хмурением.'
@ -542,6 +579,12 @@ town_npc_visit:
tonic_steams_table: 'На столике дымится отвар; надеешься, он не для тебя.'
blessings_salves_bandages: 'Бормочут благословения, перекладывая мази и бинты.'
lighter_under_canvas: 'Стоя под пологом, чувствуешь себя странно легче.'
needle_flash_quick: 'Мелькает игла; ты отводишь взгляд — будто храбрость опциональна.'
wash_basin_cloudy: 'Умывальник мутный — честность в сантехнике.'
herb_bundle_label_faded: 'Пучки трав с этикетками, давно растворившимися в миф.'
whisper_count_pulse: 'Шепчут цифры — пульс или цена, не разобрать.'
lint_free_bandage_brag: 'Хвастаются бинтами без ворса. Ты почти веришь.'
bitter_tea_offer: 'Горький чай — лекарство или наказание. И то и другое.'
quest_giver:
scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.'
ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.'
@ -549,6 +592,12 @@ town_npc_visit:
draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.'
squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.'
promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.'
seal_crack_important: 'Трескается печать — и это значит «важно».'
chair_squeak_dramatic: 'Стул скрипит вовремя — звукорежиссёр не получил золото.'
window_draft_story: 'Сквозняк из окна приносит чужую историю.'
stamp_ink_thumb: 'Чернила на пальце — второй печати хватает.'
reward_bag_heavier: 'Мешок с наградой выглядит тяжелее совести.'
last_hero_failed_joke: 'Шутят, что прошлый герой «ошибся вверх». Ха.'
generic:
town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.'
grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.'
@ -556,3 +605,9 @@ town_npc_visit:
strap_tighten_pretend: 'Подтягиваешь ремень и делаешь вид, что так и задумано.'
dog_boring_sleeps: 'Собака смотрит, решает, что ты скучен, и засыпает.'
breathe_ready_move_on: 'Выдыхаешь — готов идти дальше, когда будет пора.'
bell_distant_smith: 'Далёкий колокол кузни спорит с полуденным зноем.'
child_chasing_chicken: 'Ребёнок гоняет курицу — цивилизация держится.'
rain_barrel_drip: 'С бочки капает дождь — медленная перкуссия.'
cloak_smell_smoke: 'Плащ пахнет дымом и более старыми дорогами.'
notice_board_torn: 'Доска объявлений изорвана вполовину — оптимизм с зубами.'
two_guards_yawn: 'Два стража зевают синхронно — дисциплина, но сонная.'

Loading…
Cancel
Save