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
|
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
| 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
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 "E 出屋" 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 "E 进屋" 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 "E 交谈" 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
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
storyWorld,
{ view with
Panel = WorldPanel
Menu = None
Chronicle = Sim.annalText storyWorld
Prompt = None
PromptTargetPos = None
Task = storyTask
Status = 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 -> view.Panel = DialoguePanel
| 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
| Observe ->
let observations = Sim.observeVisible (observationBounds world) world
world,
{ view with
Panel = ObservationPanel
Observations = observations
Prompt = None
PromptTargetPos = None
Status = 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("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) ->
sprintf "交易:村民 %d 从村民 %d 获得%s ×%d,单价 %.2f"
(npcNumber buyer)
(npcNumber 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 @ [ "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 ->
[ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ]
let storyLine =
sprintf "剧情线:%s,%s"
(Occupation.Story.lineNameOf occupationState.Profile.Kind)
(Occupation.Story.stageNameOf occupationState.StoryStage)
Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ storyLine ] @ [ status ]
|