diff --git a/backend/internal/game/hero_meet.go b/backend/internal/game/hero_meet.go index 1076664..fe11486 100644 --- a/backend/internal/game/hero_meet.go +++ b/backend/internal/game/hero_meet.go @@ -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 { diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 885059f..564872a 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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 diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go index b971c31..a254c91 100644 --- a/backend/internal/model/adventure_log_phrase_keys.go +++ b/backend/internal/model/adventure_log_phrase_keys.go @@ -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))] +} diff --git a/backend/internal/model/adventure_log_phrase_keys_test.go b/backend/internal/model/adventure_log_phrase_keys_test.go index 15482a3..e241e39 100644 --- a/backend/internal/model/adventure_log_phrase_keys_test.go +++ b/backend/internal/model/adventure_log_phrase_keys_test.go @@ -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) + } + } +} diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 8fea9ba..e0a6674 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -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++ diff --git a/backend/internal/model/hero_meet_lines.go b/backend/internal/model/hero_meet_lines.go index 6d51263..e29544d 100644 --- a/backend/internal/model/hero_meet_lines.go +++ b/backend/internal/model/hero_meet_lines.go @@ -1,5 +1,7 @@ package model +import "math/rand" + // HeroMeetAutoLineSlugs are stable ids for auto-dialogue (client localizes hero_meet.auto.). 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 +} diff --git a/backend/internal/model/hero_meet_lines_test.go b/backend/internal/model/hero_meet_lines_test.go new file mode 100644 index 0000000..4678352 --- /dev/null +++ b/backend/internal/model/hero_meet_lines_test.go @@ -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) + } + } +} diff --git a/backend/internal/model/roadside_phrase_keys.go b/backend/internal/model/roadside_phrase_keys.go index f06bbf2..f4d3901 100644 --- a/backend/internal/model/roadside_phrase_keys.go +++ b/backend/internal/model/roadside_phrase_keys.go @@ -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. diff --git a/backend/migrations/000030_world_expansion_spiral_towns.sql b/backend/migrations/000030_world_expansion_spiral_towns.sql new file mode 100644 index 0000000..21ec9cc --- /dev/null +++ b/backend/migrations/000030_world_expansion_spiral_towns.sql @@ -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); diff --git a/backend/migrations/000031_town_positions_min_travel_time.sql b/backend/migrations/000031_town_positions_min_travel_time.sql new file mode 100644 index 0000000..ac01ccf --- /dev/null +++ b/backend/migrations/000031_town_positions_min_travel_time.sql @@ -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 8–2) 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; diff --git a/frontend/src/i18n/contentLabels.ts b/frontend/src/i18n/contentLabels.ts index 8c82aad..9c3f273 100644 --- a/frontend/src/i18n/contentLabels.ts +++ b/frontend/src/i18n/contentLabels.ts @@ -20,6 +20,26 @@ export const TOWN_ID_TO_NAME_KEY: Record = { 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 = { '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 = { @@ -58,6 +98,88 @@ const NPCS: Record = { '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 = { diff --git a/frontend/src/i18n/en.yml b/frontend/src/i18n/en.yml index 738603e..420fa5d 100644 --- a/frontend/src/i18n/en.yml +++ b/frontend/src/i18n/en.yml @@ -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 giver’s 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. diff --git a/frontend/src/i18n/ru.yml b/frontend/src/i18n/ru.yml index d25febf..28592be 100644 --- a/frontend/src/i18n/ru.yml +++ b/frontend/src/i18n/ru.yml @@ -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: 'Два стража зевают синхронно — дисциплина, но сонная.'