summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/Interaction.fs
blob: 3a65e8e23357a7d5f2ab8374fd87f383ffadfc28 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
namespace LivingVillage.Desktop

open System
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop.VillagePresentation

type InteractionAction =
    | TalkTo of NpcId
    | EnterHome of HomeId
    | ExitHome of HomeId

type InteractionTarget =
    { Action: InteractionAction
      Position: Vec2
      Text: string
      DistanceSquared: float32 }

type M5Panel =
    | WorldPanel
    | NeedsPanel
    | ObservationPanel
    | DialoguePanel
    | ChroniclePanel
    | TaskPanel

type M5Command =
    | Interact
    | Intent1
    | Intent2
    | Intent3
    | Intent4
    | Intent5
    | Intent6
    | BuyFromTarget
    | SellToTarget
    | AcceptTask
    | Observe
    | ToggleNeeds
    | ShowChronicle
    | ShowTaskPanel
    | ClosePanel

type M5View =
    { Panel: M5Panel
      Menu: DialogueMenu option
      Needs: NeedsPanel option
      Observations: Observation list
      Chronicle: string
      Status: string
      StatusTick: int64 option
      HomeMode: HomeMode
      Prompt: string option
      PromptTargetPos: Vec2 option
      Task: Occupation.State option
      /// 世界种子(与 Game.fs 的开局种子同源),供剧情线开线日哈希使用。
      Seed: uint64 }

module InteractionResolver =

    let private tileCenter (tile: TilePosition) : Vec2 =
        { X = float32 (tile.X * Sim.tilePixels + Sim.tilePixels / 2)
          Y = float32 (tile.Y * Sim.tilePixels + Sim.tilePixels / 2) }

    let private homeDoorPosition (home: Home) : Vec2 =
        home.Door |> worldTileForSceneTile |> tileCenter

    let private blockingTiles : Set<int * int> =
        scene.Elements
        |> List.choose (fun element ->
            match element.Prop with
            | WhiteWallDarkTileHome ->
                let tile = worldTileForSceneTile element.Position
                Some(tile.X, tile.Y)
            | _ -> None)
        |> Set.ofList

    let private tileAt (position: Vec2) : int * int =
        int (Math.Floor(float position.X / float Sim.tilePixels)),
        int (Math.Floor(float position.Y / float Sim.tilePixels))

    let hasClearPath (fromPosition: Vec2) (targetPosition: Vec2) : bool =
        let dx = targetPosition.X - fromPosition.X
        let dy = targetPosition.Y - fromPosition.Y
        let distance = sqrt (dx * dx + dy * dy)
        let steps = max 1 (int (Math.Ceiling(float (distance / float32 Sim.tilePixels * 4.0f))))
        [ 1 .. steps - 1 ]
        |> List.forall (fun step ->
            let fraction = float32 step / float32 steps
            let sample : Vec2 =
                { X = fromPosition.X + dx * fraction
                  Y = fromPosition.Y + dy * fraction }
            not (Set.contains (tileAt sample) blockingTiles))

    let private distanceSquared (first: Vec2) (second: Vec2) : float32 =
        let dx = second.X - first.X
        let dy = second.Y - first.Y
        dx * dx + dy * dy

    let private dialogueAvailable (npc: Npc) : bool =
        npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat

    let private npcNumber (NpcId id) = id

    let private target action position text avatarPosition : InteractionTarget =
        { Action = action
          Position = position
          Text = text
          DistanceSquared = distanceSquared avatarPosition position }

    let private targetIsReachable (avatarPosition: Vec2) (position: Vec2) : bool =
        distanceSquared avatarPosition position <= Sim.chatRangeSq
        && hasClearPath avatarPosition position

    /// P53:交互动作 → 情境提示文案的唯一映射。提示层与按 E 的实际作用对象共用同一
    /// 选择规则,文案只由动作决定,保证「提示写 E 交谈 → 按 E 作用于同一目标」。
    let actionText (action: InteractionAction) : string =
        match action with
        | TalkTo _ -> "E 交谈"
        | EnterHome _ -> "E 进屋"
        | ExitHome _ -> "E 出屋"

    let resolve (world: World) (homeMode: HomeMode) : InteractionTarget option =
        let avatarPosition = world.Avatar.Pos
        let candidates =
            match homeMode with
            | Inside homeId ->
                scene.Homes
                |> List.choose (fun home ->
                    if home.Id = homeId then
                        let position = homeDoorPosition home
                        let avatarTile =
                            sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y }
                        if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then
                            Some(target (ExitHome home.Id) position (actionText (ExitHome home.Id)) avatarPosition)
                        else None
                    else None)
            | Outside ->
                let homes =
                    scene.Homes
                    |> List.choose (fun home ->
                        let position = homeDoorPosition home
                        let avatarTile =
                            sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y }
                        if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then
                            Some(target (EnterHome home.Id) position (actionText (EnterHome home.Id)) avatarPosition)
                        else None)
                let npcs =
                    world.Npcs
                    |> Array.choose (fun npc ->
                        if dialogueAvailable npc && targetIsReachable avatarPosition npc.Pos then
                            Some(target (TalkTo npc.Id) npc.Pos (actionText (TalkTo npc.Id)) avatarPosition)
                        else None)
                    |> Array.toList
                homes @ npcs

        candidates
        |> List.sortBy (fun candidate ->
            let stableId =
                match candidate.Action with
                | TalkTo id -> npcNumber id
                | EnterHome(HomeId id)
                | ExitHome(HomeId id) -> id
            candidate.DistanceSquared, stableId)
        |> List.tryHead

    let resolveAction = resolve

module FloatingPrompt =

    type PromptRect =
        { X: int
          Y: int
          Width: int
          Height: int }

    // HUD exclusion geometry shared with the Game.fs renderer: full-width top status bar
    // and the bottom-left lightweight panel band.
    let hudTopBarHeight = 39
    let hudBottomBandHeight = 160

    /// Width of text as PixelText.draw advances it (scale applied, CJK 8px, latin 6px, space 4px).
    let measureWidth (text: string) (scale: int) : int =
        text.ToUpperInvariant()
        |> Seq.fold (fun cursor character ->
            if character = ' ' then cursor + 4 * scale
            elif CjkGlyphAtlas.sourceRectangle character |> Option.isSome then cursor + 8 * scale
            else cursor + 6 * scale) 0

    /// Anchor the one short prompt above the unique target: horizontally centered on the
    /// entity, clamped inside the viewport, and never covering either HUD band.
    let layout
        (viewportWidth: int)
        (viewportHeight: int)
        (anchorWorldX: float32)
        (anchorWorldY: float32)
        (cameraX: float32)
        (cameraY: float32)
        (scale: int)
        (text: string) : PromptRect =
        let width = measureWidth text scale
        let height = 8 * scale + 10
        let margin = 8
        let screenX = int anchorWorldX - int cameraX - width / 2
        let screenY = int anchorWorldY - int cameraY - 56
        let x =
            if width + margin * 2 > viewportWidth then margin
            else max margin (min (viewportWidth - margin - width) screenX)
        let topSafe = hudTopBarHeight + margin
        let bottomSafe = viewportHeight - hudBottomBandHeight - height - margin
        let y = max topSafe (min bottomSafe screenY)
        { X = x; Y = y; Width = width; Height = height }

module M5Interaction =

    let initial : M5View =
        { Panel = WorldPanel
          Menu = None
          Needs = None
          Observations = []
          Chronicle = ""
          Status = "ready"
          StatusTick = None
          HomeMode = Outside
          Prompt = None
          PromptTargetPos = None
          Task = None
          Seed = 42UL }

    let worldInputAllowed (view: M5View) : bool =
        view.Panel = WorldPanel

    let refreshPrompt (world: World) (view: M5View) : M5View =
        if worldInputAllowed view then
            let resolved = InteractionResolver.resolve world view.HomeMode
            { view with
                Prompt = resolved |> Option.map (fun target -> target.Text)
                PromptTargetPos = resolved |> Option.map (fun target -> target.Position) }
        else
            { view with Prompt = None; PromptTargetPos = None }

    let resolveAction (world: World) (homeMode: HomeMode) : InteractionTarget option =
        InteractionResolver.resolveAction world homeMode

    let private observationBounds (world: World) : VisibleBounds =
        let radius = Sim.chatRangePx
        { Min = { X = world.Avatar.Pos.X - radius; Y = world.Avatar.Pos.Y - radius }
          Max = { X = world.Avatar.Pos.X + radius; Y = world.Avatar.Pos.Y + radius } }

    let private menuStatus (menu: DialogueMenu) : string =
        sprintf "dialogue target=%A options=%d; press 1-6" menu.Target menu.Options.Length

    let private intentName (intent: DialogueIntent) : string =
        match intent with
        | SmallTalk -> "闲聊"
        | AskHelp -> "求助"
        | OfferTrade -> "提议交易"
        | Joke -> "讲笑话"
        | Apologize -> "道歉"
        | Provoke -> "挑衅"

    let private needName (kind: NeedKind) : string =
        match kind with
        | HungerNeed -> "饥饿"
        | EnergyNeed -> "精力"
        | SocialNeed -> "社交"
        | MoneyNeed -> "金钱"

    let private itemName (item: ItemKind) : string =
        match item with
        | Food -> "食物"
        | Fish -> "鱼"
        | Spice -> "香料"
        | Scroll -> "卷轴"

    let private responseName (response: DialogueResponse) : string =
        match response with
        | Friendly -> "友好"
        | Helpful -> "乐于助人"
        | Bargaining -> "讨价还价"
        | Amused -> "开心"
        | Forgiving -> "原谅"
        | Hostile -> "敌意"
        | Reserved -> "保留"
        | Refused -> "拒绝"
        | Offended -> "恼怒"

    let private dialogueFailureName (failure: DialogueFailure) : string =
        match failure with
        | DialogueTargetNotFound -> "找不到目标"
        | DialogueTargetOutOfRange -> "目标不在范围内"
        | DialogueTargetUnavailable -> "目标暂时无法互动"

    let private tradeFailureName (failure: TradeFailure) : string =
        match failure with
        | BuyerNotFound -> "找不到买家"
        | SellerNotFound -> "找不到卖家"
        | SameParticipant -> "买家和卖家不能是同一人"
        | InvalidQuantity -> "数量必须大于零"
        | OutOfStock -> "库存不足"
        | InsufficientFunds -> "资金不足"

    let private npcNumber (NpcId id) = id

    let menuNpcNumber (menu: DialogueMenu) : int = npcNumber menu.Target

    let intentLine (intent: DialogueIntent) : string = intentName intent

    let private chooseIntent (index: int) (world: World) (view: M5View) : World * M5View =
        match view.Menu with
        | None -> world, { view with Status = "no dialogue menu; press E near an NPC" }
        | Some menu ->
            match List.tryItem index menu.Options with
            | None -> world, { view with Status = "invalid dialogue option" }
            | Some intent ->
            // P-d:回应经职业偏差纯函数;无职业走既有 identity 路径(逐字节不变)。
            let respond =
                match view.Task with
                | Some task -> Occupation.biasResponse (Some task.Profile.Kind)
                | None -> Sim.responseFor
            // P65:货郎打听行情读**对话前**既有谣言池,产出中文行情短答。
            // 非货郎 / 无职业 / 无命中谣言 → None,落回既有 dialogue 文案(旧行为逐字节不变)。
            let inquiry =
                match view.Task with
                | Some task -> Occupation.marketInquiryText (Some task.Profile.Kind) intent world
                | None -> None
            match Sim.chooseDialogueWith respond menu.Target intent world with
                | DialogueSucceeded(outcome, next) ->
                    // P-d 剧情线:每次玩家对话至多推进一段,摘要写年鉴(StoryAnnal)。
                    let storyWorld, storyTask =
                        match view.Task with
                        | Some task ->
                            let nextState, summary = Occupation.Story.onDialogue view.Seed task next
                            let withAnnal =
                                match summary with
                                | Some text ->
                                    Sim.appendAnnal
                                        { Tick = next.Tick; Kind = StoryAnnal; Summary = text }
                                        next
                                | None -> next
                            withAnnal, Some nextState
                        | None -> next, None
                    // P54:玩家对话是真实事件(Actor=玩家),驱动对话类当日任务。
                    let advanced, taskMessage =
                        match storyTask with
                        | Some taskState ->
                            let next, message =
                                TaskRuntime.advanceWithEvent
                                    { Tick = storyWorld.Tick; Kind = DialogueEvent outcome } taskState
                            Some next, message
                        | None -> None, None
                    storyWorld,
                    { view with
                        Panel = WorldPanel
                        Menu = None
                        Chronicle = Sim.annalText storyWorld
                        Prompt = None
                        PromptTargetPos = None
                        Task = advanced
                        Status =
                            match inquiry with
                            | Some text -> "inquiry|" + text
                            | None ->
                                match taskMessage with
                                | Some message -> "task|" + message
                                | None -> sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
                        StatusTick = Some storyWorld.Tick }
                | DialogueRejected(failure, next) ->
                    next,
                    { view with
                        Panel = WorldPanel
                        Menu = None
                        Prompt = None
                        PromptTargetPos = None
                        Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure)
                        StatusTick = Some next.Tick }

    let private commandAllowed (command: M5Command) (view: M5View) : bool =
        match command with
        | ClosePanel -> true
        | Interact -> view.Panel = WorldPanel
        | Intent1
        | Intent2
        | Intent3
        | Intent4
        | Intent5
        | Intent6
        | BuyFromTarget
        | SellToTarget -> view.Panel = DialoguePanel
        | AcceptTask -> view.Panel = TaskPanel
        | Observe
        | ToggleNeeds
        | ShowChronicle
        | ShowTaskPanel -> view.Panel <> DialoguePanel

    let private applyAllowed (command: M5Command) (world: World) (view: M5View) : World * M5View =
        match command with
        | Interact ->
            match InteractionResolver.resolve world view.HomeMode with
            | Some { Action = TalkTo target } ->
                let menu = { Target = target; Options = Sim.dialogueOptions }
                world,
                { view with
                    Panel = DialoguePanel
                    Menu = Some menu
                    Prompt = None
                    PromptTargetPos = None
                    Status = menuStatus menu }
            | Some { Action = EnterHome _ } ->
                let pixel = { PixelPosition.X = world.Avatar.Pos.X; Y = world.Avatar.Pos.Y }
                let scenePosition = sceneTileFromPixel pixel
                match enterHome scenePosition view.HomeMode with
                | Ok nextMode ->
                    world,
                    { view with
                        Panel = WorldPanel
                        Menu = None
                        HomeMode = nextMode
                        Prompt = None
                        PromptTargetPos = None
                        Status = "home entered" }
                | Error failure -> world, { view with Prompt = None; Status = sprintf "home entry rejected: %A" failure }
            | Some { Action = ExitHome _ } ->
                match exitHome view.HomeMode with
                | Ok nextMode ->
                    world,
                    { view with
                        Panel = WorldPanel
                        Menu = None
                        HomeMode = nextMode
                        Prompt = None
                        PromptTargetPos = None
                        Status = "home exited" }
                | Error failure -> world, { view with Prompt = None; Status = sprintf "home exit rejected: %A" failure }
            | None ->
                world,
                { view with Panel = WorldPanel; Menu = None; Prompt = None; Status = "no nearby interaction" }
        | Intent1 -> chooseIntent 0 world view
        | Intent2 -> chooseIntent 1 world view
        | Intent3 -> chooseIntent 2 world view
        | Intent4 -> chooseIntent 3 world view
        | Intent5 -> chooseIntent 4 world view
        | Intent6 -> chooseIntent 5 world view
        | BuyFromTarget
        | SellToTarget ->
            match view.Menu with
            | None -> world, { view with Status = "no dialogue menu; press E near an NPC" }
            | Some menu ->
                // P52:玩家买/卖走既有 Trade 路径(Sim.quotePrice → Occupation.biasedQuote),
                // 成交后回到世界面板,让短暂反馈通道显示结果。
                let outcome =
                    if command = BuyFromTarget then PlayerTrade.buyFood view.Task menu.Target world
                    else PlayerTrade.sellFirst view.Task menu.Target world
                // P54/P55:交易完成当日任务时把(含奖励的)短中文反馈一并带出。
                let taskMessage = TaskRuntime.completedMessageWithReward view.Task outcome.Occupation outcome.Reward
                outcome.World,
                { view with
                    Panel = WorldPanel
                    Menu = None
                    Prompt = None
                    PromptTargetPos = None
                    Task = outcome.Occupation
                    Status =
                        (if outcome.Succeeded then "trade ok|" else "trade fail|")
                        + outcome.Message
                        + (match taskMessage with Some message -> "|" + message | None -> "")
                    StatusTick = Some outcome.World.Tick }
        | AcceptTask ->
            match view.Task with
            | None -> world, { view with Status = "task|无职业任务" }
            | Some occupationState ->
                let next, message = TaskRuntime.accept occupationState
                world,
                { view with
                    Task = Some next
                    Status = "task|" + (message |> Option.defaultValue "当前任务无需接受") }
        | Observe ->
            let observations = Sim.observeVisible (observationBounds world) world
            // P54:「观察」是真实动作,驱动观察记事类任务。
            let task, taskMessage =
                match view.Task with
                | Some occupationState ->
                    let next, message = TaskRuntime.advanceWithSignal Occupation.Observed occupationState
                    Some next, message
                | None -> None, None
            world,
                { view with
                    Panel = ObservationPanel
                    Observations = observations
                    Task = task
                    Prompt = None
                    PromptTargetPos = None
                    Status =
                        match taskMessage with
                        | Some message -> "task|" + message
                        | None -> sprintf "observation visible_npcs=%d" observations.Length }
        | ToggleNeeds ->
            if view.Panel = NeedsPanel then
                world, refreshPrompt world { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" }
            else
                world,
                { view with
                    Panel = NeedsPanel
                    Needs = Some(Sim.needsPanel world)
                    Prompt = None
                    PromptTargetPos = None
                    Status = "needs panel open" }
        | ShowChronicle ->
            world,
            { view with
                Panel = ChroniclePanel
                Chronicle = Sim.annalText world
                Prompt = None
                PromptTargetPos = None
                Status = sprintf "chronicle entries=%d" world.Annals.Length }
        | ShowTaskPanel ->
            world,
            { view with
                Panel = TaskPanel
                Prompt = None
                PromptTargetPos = None
                Status = "task panel open" }
        | ClosePanel -> world, refreshPrompt world { view with Panel = WorldPanel; Menu = None; Status = "panel closed" }

    let apply (command: M5Command) (world: World) (view: M5View) : World * M5View =
        if commandAllowed command view then
            let nextWorld, nextView = applyAllowed command world view
            let stamped =
                if nextView.Status <> view.Status && Option.isNone nextView.StatusTick then
                    { nextView with StatusTick = Some nextWorld.Tick }
                else
                    nextView
            nextWorld, stamped
        else
            world, view

    let statusDisplayDurationTicks = 150L

    let tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string =
        match result with
        | TradeSucceeded _ ->
            match quote with
            | Some unitPrice ->
                sprintf "交易成功:%s ×%d,报价 %.2f,总价 %.2f"
                    (itemName request.Item)
                    request.Quantity
                    unitPrice
                    (unitPrice * float32 request.Quantity)
            | None -> sprintf "交易成功:%s ×%d" (itemName request.Item) request.Quantity
        | TradeRejected(failure, _) -> sprintf "交易失败:%s" (tradeFailureName failure)

    let panelName (view: M5View) : string =
        match view.Panel with
        | WorldPanel -> "world"
        | NeedsPanel -> "needs"
        | ObservationPanel -> "observation"
        | DialoguePanel -> "dialogue"
        | ChroniclePanel -> "chronicle"
        | TaskPanel -> "task"

    let titleText (view: M5View) : string =
        match view.Panel with
        | WorldPanel -> "江南水乡"
        | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴"
        | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭"
        | DialoguePanel ->
            match view.Menu with
            | Some menu ->
                let options =
                    menu.Options
                    |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent))
                    |> String.concat " | "
                sprintf "互动:村民 %d | %s | ESC 关闭" (npcNumber menu.Target) options
            | None -> "互动已记录 | E 互动 | ESC 关闭"
        | ChroniclePanel -> "年鉴 | TAB 需求 | Q 观察 | ESC 关闭"
        | TaskPanel -> "任务 | TAB 需求 | Q 观察 | ESC 关闭"

    let private statusLabel (status: string) : string =
        match status with
        | "ready" -> "准备就绪"
        | "needs panel open" -> "需求面板已打开"
        | "needs panel closed" -> "需求面板已关闭"
        | "panel closed" -> "面板已关闭"
        | "home entered" -> "已进入民居"
        | "home exited" -> "已离开民居"
        | "save succeeded" -> "保存成功"
        | "save failed" -> "保存失败"
        | value when value.StartsWith("dialogue target=") -> "请选择对话方式"
        | value when value.StartsWith("dialogue intent=") -> "对话完成"
        | value when value.StartsWith("dialogue rejected:") ->
            let reason = value.Substring("dialogue rejected:".Length).Trim()
            if String.IsNullOrWhiteSpace reason then "对话失败" else sprintf "对话失败:%s" reason
        | value when value.StartsWith("observation") -> "观察完成"
        | value when value.StartsWith("chronicle") -> "年鉴面板已打开"
        | "task panel open" -> "任务面板已打开"
        | value when value.StartsWith("task|") -> value.Substring("task|".Length)
        | value when value.StartsWith("inquiry|") -> value.Substring("inquiry|".Length)
        | value when value.StartsWith("trade ok|") -> value.Substring("trade ok|".Length)
        | value when value.StartsWith("trade fail|") -> "交易失败:" + value.Substring("trade fail|".Length)
        | value when value.StartsWith("no dialogue") -> "当前没有可用对话"
        | value when value.StartsWith("invalid dialogue") -> "对话选项无效"
        | value when value.StartsWith("no nearby") -> "附近没有可互动目标"
        | _ -> "状态已更新"

    let statusText (view: M5View) : string =
        if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status

    let transientStatusLine (nowTick: int64) (view: M5View) : string option =
        match view.StatusTick with
        | Some tick when view.Panel = WorldPanel && nowTick - tick < statusDisplayDurationTicks ->
            Some(statusText view)
        | _ -> None

    /// Minimal corner status for the persistent HUD: day counter plus money/energy taken
    /// directly from the avatar's real needs (no invented values).
    let cornerStatusLines (world: World) : string list =
        let day = world.Tick / Sim.ticksPerDay + 1L
        let needs = world.Avatar.Mind.Needs
        [ sprintf "天数 %d" day
          sprintf "金钱 %.0f / 精力 %.0f" needs.Money needs.Energy ]

    let private annalLine (entry: AnnalEntry) : string =
        match entry.Kind with
        | DialogueAnnal outcome ->
            sprintf "对话:村民 %d 对村民 %d 使用%s,回应%s"
                (npcNumber outcome.Actor)
                (npcNumber outcome.Target)
                (intentName outcome.Intent)
                (responseName outcome.Response)
        | RumorAnnal rumor ->
            sprintf "传闻:村民 %d 传给村民 %d,传播 %d 层"
                (npcNumber rumor.Narrator)
                (npcNumber rumor.Receiver)
                rumor.Depth
        | TradeAnnal event ->
            match event.Kind with
            | TradeEvent(buyer, seller, item, quantity, unitPrice) ->
                // P52:玩家交易以 playerId 记事件,年鉴里显示为「玩家」而非「村民 -1」。
                let participant (id: NpcId) =
                    if id = playerId then "玩家" else sprintf "村民 %d" (npcNumber id)
                sprintf "交易:%s 从 %s 获得%s ×%d,单价 %.2f"
                    (participant buyer)
                    (participant seller)
                    (itemName item)
                    quantity
                    unitPrice
            | _ -> "交易记录"
        | StoryAnnal -> entry.Summary

    let private chronicleLines (world: World) : string list =
        world.Annals
        |> List.rev
        |> List.truncate 10
        |> List.map annalLine

    let panelLines (world: World) (view: M5View) : string list =
        let status = statusText view
        match view.Panel with
        | WorldPanel ->
            (view.Prompt |> Option.toList)
            @ (transientStatusLine world.Tick view |> Option.toList)
        | NeedsPanel ->
            match view.Needs with
            | None -> [ "需求"; "暂无数据"; status ]
            | Some panel ->
                let topNeed =
                    panel.Ranked
                    |> List.tryHead
                    |> Option.map (fun score -> sprintf "最紧迫:%s" (needName score.Kind))
                    |> Option.defaultValue "暂无紧迫需求"
                [ "需求"
                  sprintf "饥饿 %.0f" panel.Needs.Hunger
                  sprintf "精力 %.0f" panel.Needs.Energy
                  sprintf "社交 %.0f" panel.Needs.Social
                  sprintf "金钱 %.0f" panel.Needs.Money
                  topNeed
                  status ]
        | ObservationPanel ->
            let rows =
                view.Observations
                |> List.truncate 8
                |> List.collect (fun observation ->
                    let urgency =
                        observation.Ranked
                        |> List.tryHead
                        |> Option.map (fun score -> score.Urgency)
                        |> Option.defaultValue 0.0f
                    [ sprintf "村民 %d 紧迫度 %.0f" (npcNumber observation.Id) urgency
                      sprintf "饥饿 %.0f 精力 %.0f 社交 %.0f 金钱 %.0f"
                          observation.Needs.Hunger
                          observation.Needs.Energy
                          observation.Needs.Social
                          observation.Needs.Money ])
            "观察" :: (if rows.IsEmpty then [ "范围内暂无村民" ] else rows) @ [ status ]
        | DialoguePanel ->
            match view.Menu with
            | None -> [ "互动已记录"; status ]
            | Some menu ->
                let options =
                    menu.Options
                    |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent))
                [ sprintf "互动:村民 %d" (npcNumber menu.Target) ]
                @ options
                @ [ "B 买入 食物×1"; "V 卖出 背包物品×1"; "ESC 关闭"; status ]
        | ChroniclePanel ->
            let entries = chronicleLines world
            "年鉴" :: (if entries.IsEmpty then [ "暂无记录" ] else entries) @ [ status ]
        | TaskPanel ->
            let taskStateText (state: Occupation.TaskState) : string =
                match state with
                | Occupation.Offered -> "待接受"
                | Occupation.Active -> "进行中"
                | Occupation.Done -> "已完成"
                | Occupation.Failed -> "已失败"
            match view.Task with
            | None -> [ "任务"; "无职业"; status ]
            | Some occupationState ->
                let taskLines =
                    match occupationState.Today with
                    | None -> [ "今日暂无任务" ]
                    | Some task ->
                        [ TaskRuntime.taskLine task (taskStateText task.State) ]
                        @ (match task.State with
                           | Occupation.Offered -> [ "回车:接受任务" ]
                           | _ -> [])
                let storyLine =
                    sprintf "剧情线:%s,%s"
                        (Occupation.Story.lineNameOf occupationState.Profile.Kind)
                        (Occupation.Story.stageNameOf occupationState.StoryStage)
                Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ storyLine ] @ [ status ]