-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
2650 lines (2410 loc) · 122 KB
/
Copy pathserver.py
File metadata and controls
2650 lines (2410 loc) · 122 KB
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
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
# -*- coding: utf-8 -*-
'平面设计师职业生涯模拟器 - 游戏服务器 v2'
import json, os, random, traceback, uuid, threading
from datetime import datetime, timedelta
from flask import Flask, request, jsonify, send_from_directory, session
app = Flask(__name__, static_folder='static', static_url_path='')
app.secret_key = os.environ.get('SECRET_KEY', 'designer-game-fixed-key-2024')
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# ============================================================
# Global crash logger — catches ALL unhandled exceptions
# ============================================================
@app.errorhandler(Exception)
def handle_crash(e):
import traceback as tb
with open('_crash.log', 'a', encoding='utf-8') as f:
f.write(f'\n[{datetime.now().isoformat()}] {type(e).__name__}: {e}\n')
f.write(tb.format_exc())
f.write('\n' + '='*60 + '\n')
return jsonify({'error': f'服务器异常: {str(e)[:100]}'}), 500
BASE_DIR = os.path.dirname(__file__)
CONFIG_FILE = os.path.join(BASE_DIR, 'config.json')
PLAYERS_DIR = os.path.join(BASE_DIR, 'players')
# ============================================================
# Time / Calendar System — 真实日历 + 季节事件
# ============================================================
def get_game_date(state):
'''Calculate real calendar date from start_date + turns (1 turn = 1 week).'''
start = state.get('start_date')
if not start:
return datetime.now().strftime('%Y年%m月')
try:
start_dt = datetime.fromisoformat(start)
current = start_dt + timedelta(weeks=state.get('turn_count', 0))
return current.strftime('%Y年%m月')
except:
return datetime.now().strftime('%Y年%m月')
def get_season_and_events(date_str):
'''Return (season_name, [event_hints]) for a calendar date.'''
try:
month = int(date_str.split('年')[1].replace('月', ''))
except:
month = datetime.now().month
if month in [3, 4, 5]:
return '春季', ['金三银四招聘旺季', '毕业季应届生涌入市场', '春季行业展会密集']
elif month in [6, 7, 8]:
return '夏季', ['年中总结和评审', '暑期实习生活跃', '夏季高温易疲劳']
elif month in [9, 10, 11]:
return '秋季', ['秋招季人才流动加剧', '各大设计周/设计展举办', '年底项目冲刺']
else:
return '冬季', ['年终评审和年终奖', '春节前项目收尾催稿', '年会和行业聚会密集']
SEASON_EVENT_HINTS = {
(3,4,5): {'tag': '行业事件', 'hint': '春季招聘市场活跃,猎头和HR频繁联系'},
(6,7,8): {'tag': '转折点', 'hint': '年中评审——是争取升职加薪的关键窗口'},
(9,10,11): {'tag': '项目推进', 'hint': '设计周密集举办,年底项目冲刺'},
(12,1,2): {'tag': '转折点', 'hint': '年终总结和绩效考核,春节前后节奏放缓'},
}
def get_season_llm_hint(month):
for months, info in SEASON_EVENT_HINTS.items():
if month in months:
return info
return {'tag': '日常', 'hint': ''}
# ============================================================
# Session-based State Management — 多玩家隔离
# ============================================================
def ensure_players_dir():
if not os.path.exists(PLAYERS_DIR):
os.makedirs(PLAYERS_DIR)
def get_session_id():
'''Generate or retrieve persistent session ID.'''
if 'player_id' not in session:
session['player_id'] = uuid.uuid4().hex[:12]
session.permanent = True
return session['player_id']
def get_state_file():
pid = get_session_id()
ensure_players_dir()
return os.path.join(PLAYERS_DIR, f'state_{pid}.json')
def load_state():
path = get_state_file()
if os.path.exists(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
return None
def save_state(state):
path = get_state_file()
ensure_players_dir()
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
# Per-player thread lock — prevents concurrent write conflicts
_player_locks = {}
_pending_focus = {} # {session_id: focus_dict} — in-memory focus, merged on next save
_npc_interacted = {} # {session_id: {npc_id: True}} — per-turn NPC interaction tracking (NOT persisted)
_npc_event_log = {} # {session_id: [(npc_name, action_text)]} — recent NPC interactions for LLM context
_npc_spotlight = {} # {session_id: {npc_name: remaining_turns}} — elevated visibility window after interaction
_npc_pending_events = {} # {session_id: {npc_id: event_text}} — AI-initiated NPC events awaiting player response
def get_player_lock():
pid = get_session_id()
if pid not in _player_locks:
_player_locks[pid] = threading.Lock()
return _player_locks[pid]
def get_npc_interactions():
pid = get_session_id()
if pid not in _npc_interacted:
_npc_interacted[pid] = {}
return _npc_interacted[pid]
def flush_npc_event_log():
pid = get_session_id()
events = _npc_event_log.pop(pid, [])
return events
def push_npc_event(npc_name, action_text):
pid = get_session_id()
if pid not in _npc_event_log:
_npc_event_log[pid] = []
_npc_event_log[pid].append((npc_name, action_text))
# Grant spotlight
set_npc_spotlight(npc_name)
def set_npc_spotlight(npc_name, turns=4):
pid = get_session_id()
if pid not in _npc_spotlight:
_npc_spotlight[pid] = {}
_npc_spotlight[pid][npc_name] = max(_npc_spotlight[pid].get(npc_name, 0), turns)
def tick_npc_spotlight():
'''Decrement spotlight counters. Returns list of (name, remaining_turns) still in spotlight.'''
pid = get_session_id()
if pid not in _npc_spotlight:
return []
active = []
expired = []
for name, turns in list(_npc_spotlight[pid].items()):
if turns <= 1:
expired.append(name)
else:
_npc_spotlight[pid][name] = turns - 1
active.append((name, turns - 1))
for name in expired:
del _npc_spotlight[pid][name]
return active
# ============================================================
# Config (shared — one server, one API key)
# ============================================================
def load_config():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {'api_base': 'https://api.openai.com/v1', 'api_key': '', 'model': 'gpt-4o-mini'}
def save_config(cfg):
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
# ============================================================
# System Prompt
# ============================================================
SYSTEM_PROMPT = '''# 平面设计师模拟器 · Game Master
## 铁律
你的回复必须是纯JSON对象 ({...}),禁止任何额外文字、注释、Markdown标记。
## 身份与基调
你是DM,负责叙事/NPC/事件/属性。写实向、非爽文、无恋爱线。女主非天才,成长需代价。每3-4回合至少一次正面时刻(客户认可/同事帮忙/作品被赞/小奖金/成长感)。挫折后有回弹,低谷后有光亮。
**叙事中禁止出现精力/储蓄的具体数字**(如\"精力80\"\"储蓄3000元\"),用定性描述替代(\"精神饱满\"\"钱包吃紧\")。UI会自动显示精确数值。
## 属性 (1-20级)
前5项XP累积制(审美/执行/商业/表达/创意),作品集厚度为直接计数(每完成1项目+1)
等级由后端公式自动计算,你只输出趋势方向(up/down/flat)。
## 时间·资源
每回合≈1周。精力0-100,加班消耗,休息恢复。储蓄=工资+奖金-2500月支出。精力<20疲劳叙事,储蓄不足焦虑叙事。
## choice.effects (结构化数组)
格式: [{"attr":"审美判断力","delta":1,"text":"审美+25XP"}, {"attr":"stamina","delta":-8,"text":"精力-8"}]
attr必须是全称(审美判断力/执行能力/商业思维/表达能力/创意深度/作品集厚度),stamina/savings仅用于前端展示。
delta=1表示加25XP,delta=2表示加50XP。涨属性必须伴随代价。
大部分回合trend为flat,高等级(≥15)极少up。作品集厚度仅完成项目时up(+1)。
## 输出格式
{
"narrative": "第二人称叙事150-300字",
"choices": [
{"id":"A","text":"≤20字","hint":"≤12字","risk":"safe","effects":[{"attr":"审美判断力","delta":1,"text":"审美+25XP"},{"attr":"stamina","delta":-8,"text":"精力-8"}]},
{"id":"B","text":"≤20字","hint":"≤12字","risk":"medium","effects":[{"attr":"商业思维","delta":1,"text":"商业+25XP"}]},
{"id":"C","text":"≤20字","hint":"≤12字","risk":"high","effects":[{"attr":"表达能力","delta":1,"text":"表达+25XP"},{"attr":"stamina","delta":15,"text":"精力+15"}]}
],
"atmosphere": "≤10字",
"attr_trend": {"审美判断力":"up","执行能力":"flat","商业思维":"up","表达能力":"flat","创意深度":"flat","作品集厚度":"up"},
"stamina_change": -8, "savings_change": 0,
"event_tag": "项目推进/行业事件/日常/转折点/倦怠预警",
"npc_updates": [{"name":"陈知夏","relation":"新关系","desc":"新简介"}],
"company_update": {"name":"XX工作室","position":"初级设计师","action":"入职"}
}
## 规则速查
- stamina_change: -15~+20, 须与所选choice effects中stamina的delta一致
- attr_trend: 每个属性 up/down/flat,后端转XP
- company_update: 仅重大变动时更新(tier可选:small_studio/mid_agency/big_firm/top_tier), 否则省略
- npc_updates: 可选,仅涉及NPC时更新
- choices: 必须2-3个,每个标注risk(safe/medium/high),无终点游戏属性低不代表结束
- 每周1回合,季节影响事件倾向'''
# ============================================================
# NPC Generation
# ============================================================
NPC_TYPES = [
{'role': '行业前辈/导师', 'tags': ['资深设计师', '前老板', '教授', '评审']},
{'role': '竞争对手', 'tags': ['同级别设计师', '风格鲜明', '有野心']},
{'role': '甲方/客户', 'tags': ['品牌总监', '产品经理', '创始人']},
{'role': '合作者/搭档', 'tags': ['文案', '插画师', '摄影师', '前端开发']},
{'role': '行业暗流', 'tags': ['争议人物', '灰色地带', '利益链推手']},
{'role': '职场关系', 'tags': ['直属上级', 'HR', '合伙人']},
]
NPC_NAMES = ['陈知夏', '林墨', '苏晚晴', '方屿', '周念', '何秋池', '沈予安', '姜莱', '秦舒', '温予白', '季南星', '陆屿', '白鹿', '顾念之', '柳青禾', '徐柚']
def generate_npcs():
npcs = []
used_names = set()
for i, tpl in enumerate(NPC_TYPES):
name = random.choice([n for n in NPC_NAMES if n not in used_names])
used_names.add(name)
npcs.append({
'id': f'npc_{i}',
'name': name,
'role': tpl['role'],
'tag': random.choice(tpl['tags']),
'relation': '待剧情展开',
'desc': f'{name},{tpl["role"]}。',
'active': True
})
return npcs
# ============================================================
# LLM Call
# ============================================================
def call_llm(messages, api_base, api_key, model):
try:
import requests
headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}'}
payload = {
'model': model,
'messages': messages,
'temperature': 0.85,
'max_tokens': 3000,
'response_format': {'type': 'json_object'}
}
resp = requests.post(f'{api_base}/chat/completions', headers=headers, json=payload, timeout=120)
if resp.status_code != 200:
payload.pop('response_format', None)
resp = requests.post(f'{api_base}/chat/completions', headers=headers, json=payload, timeout=120)
# Guard against HTML error pages
raw = resp.text.strip()
if raw.startswith('<!') or raw.startswith('<html'):
return None, f'API返回HTML: {raw[:200]}'
if resp.status_code != 200:
return None, f'API错误 {resp.status_code}: {resp.text[:200]}'
# Guard against HTML error pages returned as 200
raw = resp.text.strip()
if raw.startswith('<!') or raw.startswith('<html'):
return None, f'API返回HTML而非JSON: {raw[:200]}'
data = resp.json()
content = data['choices'][0]['message']['content'].strip()
# Clean markdown code blocks
content = content.replace('```json', '').replace('```', '')
return json.loads(content), None
except json.JSONDecodeError as e:
return None, f'JSON解析失败: {str(e)[:100]}'
except Exception as e:
return None, f'调用失败: {str(e)[:100]}'
def _legacy_effect_to_structured(effect_str):
'''Convert legacy "审美+1 执行-2" to structured [{"attr":..., "delta":..., "text":...}].'''
SHORT_FULL = {
'审美': '审美判断力', '执行': '执行能力', '商业': '商业思维',
'表达': '表达能力', '创意': '创意深度', '作品': '作品集厚度'
}
result = []
parts = effect_str.strip().split()
for part in parts:
for short, full in SHORT_FULL.items():
if part.startswith(short):
try:
delta = int(part[len(short):])
result.append({'attr': full, 'delta': delta, 'text': f'{short}+{delta*25}XP' if delta > 0 else f'{short}{delta*25}XP'})
except (ValueError, IndexError):
pass
return result
# ============================================================
# Fallback choice generators
# ============================================================
def make_fallback_choices(turn_count, attrs):
'''Always return valid choices, never empty.'''
base = [
{'id': 'A', 'text': '继续当前的工作节奏', 'hint': '稳扎稳打', 'effects': [{'attr': '执行能力', 'delta': 1, 'text': '执行+25XP'}]},
{'id': 'B', 'text': '主动寻求新机会', 'hint': '冒险可能突破', 'effects': [{'attr': '商业思维', 'delta': 1, 'text': '商业+25XP'}, {'attr': 'stamina', 'delta': -8, 'text': '精力-8'}]},
{'id': 'C', 'text': '停下来复盘和思考', 'hint': '恢复和规划', 'effects': [{'attr': 'stamina', 'delta': 15, 'text': '精力+15'}]},
]
if turn_count % 7 == 0:
return [
{'id': 'A', 'text': '抓住这个转折机会', 'hint': '职业跃升', 'effects': [{'attr': '表达能力', 'delta': 1, 'text': '表达+25XP'}, {'attr': 'stamina', 'delta': -8, 'text': '精力-8'}]},
{'id': 'B', 'text': '谨慎观望再做决定', 'hint': '保守安全', 'effects': [{'attr': '执行能力', 'delta': 2, 'text': '执行+50XP'}]},
{'id': 'C', 'text': '和信任的人商量一下', 'hint': '借助他人视角', 'effects': [{'attr': '表达能力', 'delta': 1, 'text': '表达+25XP'}]},
]
return base
def validate_and_fix_result(result, turn_count, attrs):
'''Ensure the LLM result is always valid.'''
if not isinstance(result, dict):
result = {}
if not result.get('narrative'):
result['narrative'] = '日子在设计和改稿中悄然流逝。你的工作台堆满了色票和草图,窗外天色已暗。你揉了揉酸涩的眼睛,看着屏幕上的作品——还差一点,但方向是对的。下一步该怎么走?'
if not result.get('choices') or len(result['choices']) == 0:
result['choices'] = make_fallback_choices(turn_count, attrs)
# Ensure each choice has required fields
for i, ch in enumerate(result['choices']):
if not isinstance(ch, dict):
result['choices'][i] = {'id': chr(65+i), 'text': '继续推进', 'hint': '下一步', 'effects': []}
if 'id' not in ch:
ch['id'] = chr(65+i)
if 'text' not in ch:
ch['text'] = '继续推进'
if 'hint' not in ch:
ch['hint'] = ''
# Normalize effects: always use 'effects' array format
if 'effects' not in ch:
ch['effects'] = []
# Ensure 2-3 choices
if len(result['choices']) < 2:
result['choices'] = make_fallback_choices(turn_count, attrs)
if len(result['choices']) > 3:
result['choices'] = result['choices'][:3]
# Normalize all choices to effects[] format (convert legacy 'effect' strings)
for ch in result.get('choices', []):
if 'effect' in ch and isinstance(ch['effect'], str) and ch['effect'].strip():
converted = _legacy_effect_to_structured(ch['effect'])
if converted:
ch['effects'] = converted
# Keep ch['effect'] for frontend display
if 'effects' not in ch or not isinstance(ch['effects'], list):
ch['effects'] = []
if not result.get('atmosphere'):
result['atmosphere'] = '设计工作室的日常'
if not result.get('event_tag'):
result['event_tag'] = '日常'
if not result.get('attr_trend'):
result['attr_trend'] = {}
# Enforce: max 2 "up" trends per turn, rest → "flat"
up_count = 0
for key in ['审美判断力', '执行能力', '商业思维', '表达能力', '创意深度', '作品集厚度']:
if key not in result['attr_trend']:
result['attr_trend'][key] = 'flat'
elif result['attr_trend'][key] == 'up':
up_count += 1
if up_count > 2:
result['attr_trend'][key] = 'flat'
# Stamina & savings
if 'stamina_change' not in result:
result['stamina_change'] = -5
if 'savings_change' not in result:
result['savings_change'] = 0
return result
def apply_npc_updates(npcs, npc_updates):
'''Apply LLM-generated NPC relationship updates to the NPC list.'''
if not npc_updates or not isinstance(npc_updates, list):
return npcs
name_map = {n['name']: n for n in npcs}
for update in npc_updates:
if not isinstance(update, dict):
continue
name = update.get('name', '')
if name in name_map:
if 'relation' in update and update['relation']:
name_map[name]['relation'] = update['relation']
if 'desc' in update and update['desc']:
name_map[name]['desc'] = update['desc']
return npcs
def apply_company_update(state, company_update):
'''Apply company/job changes from LLM output.'''
if not company_update or not isinstance(company_update, dict):
return
action = company_update.get('action', '无变化')
if action == '无变化' or not action:
return
current = state.get('company', {})
new_company = {
'name': company_update.get('name', current.get('name', '未知')),
'position': company_update.get('position', current.get('position', '设计师')),
'action': action,
'turn': state.get('turn_count', 0)
}
# Auto-derive company tier from name/position context
name_lower = new_company['name'].lower()
pos_lower = new_company['position'].lower()
if any(w in name_lower for w in ['国际','global','4a','顶级','top']):
new_company['tier'] = 'top_tier'
elif any(w in name_lower for w in ['大厂','集团','上市','奥美','阳狮']):
new_company['tier'] = 'big_firm'
elif any(w in name_lower for w in ['工作室','studio','小','初创','独立']) or '实习' in pos_lower:
new_company['tier'] = 'small_studio'
else:
new_company['tier'] = company_update.get('tier', 'mid_agency')
# Record history
history = state.get('career_history', [])
if current and current.get('name'):
history.append({**current, 'left_at_turn': state.get('turn_count', 0)})
state['career_history'] = history
state['company'] = new_company
# ============================================================
# Career Title / Stage System
# ============================================================
# (moved up for milestone reference)
ATTR_CAP = 15 # Max attribute value (was 10)
# ============================================================
# XP-based attribute system (20-point scale)
# ============================================================
XP_PER_LEVEL_BASE = 20 # Level = floor(sqrt(xp / 20)), max level 20
def xp_to_level(xp):
'''Convert accumulated XP to level (1-20). XP can be negative.'''
if xp <= 0:
return 1
level = int((xp / XP_PER_LEVEL_BASE) ** 0.5)
return max(1, min(20, level))
def level_to_xp_target(level):
'''XP needed to reach this level from 0.'''
return level * level * XP_PER_LEVEL_BASE
def apply_trend_to_xp(state, attr_trends):
'''Apply LLM trend directions (up/down/flat) as XP changes.
Returns dict of {attr: xp_delta} for notification.'''
GOAL_BOOST = {
'成为顶级独立设计师': {'审美判断力': 10, '创意深度': 10},
'做到创意总监/合伙人': {'商业思维': 10, '表达能力': 10},
'创立自己的设计品牌/厂牌': {'商业思维': 10, '创意深度': 10},
'成为行业话语权拥有者': {'表达能力': 10, '创意深度': 10},
'活着就好': {'执行能力': 10},
}
goal = state.get('player', {}).get('goal', '')
boosts = GOAL_BOOST.get(goal, {})
deltas = {}
for attr, trend in (attr_trends or {}).items():
# 作品集厚度 = direct counter
if attr == '作品集厚度':
if trend == 'up':
count = state.get('_portfolio_count', len(state.get('portfolio', [])))
state['_portfolio_count'] = min(20, count + 1)
state['attributes'][attr] = state['_portfolio_count']
deltas[attr] = 1
continue
if trend == 'up':
delta = random.randint(15, 35)
elif trend == 'down':
delta = -random.randint(15, 35)
else:
delta = 0
# Goal bonus
if delta > 0 and attr in boosts:
delta += boosts[attr]
if delta != 0:
xp = state.get('attribute_xp', {})
xp[attr] = xp.get(attr, 0) + delta
state['attribute_xp'] = xp
deltas[attr] = delta
return deltas
def xp_to_attrs(state):
'''Derive attribute levels from XP. 作品集厚度 is a direct counter (not XP).'''
xp_dict = state.get('attribute_xp', {})
attrs = {}
XP_ATTRS = ['审美判断力', '执行能力', '商业思维', '表达能力', '创意深度']
for attr in XP_ATTRS:
attrs[attr] = xp_to_level(xp_dict.get(attr, 0))
# 作品集厚度 = direct project count
attrs['作品集厚度'] = state.get('_portfolio_count', len(state.get('portfolio', [])))
return attrs
def apply_effect_xp(state, choice_effect):
'''Apply choice effect — supports both string format and structured list.
String: "审美+1 精力-8" / Structured: [{"attr":"审美","delta":1},...]
Returns {attr: level_change} for notification.'''
level_changes = {}
# Parse string format into structured list
if isinstance(choice_effect, str) and choice_effect.strip():
SHORT_MAP = {
'审美': '审美判断力', '执行': '执行能力', '商业': '商业思维',
'表达': '表达能力', '创意': '创意深度', '作品': '作品集厚度'
}
effects_list = []
parts = choice_effect.strip().split()
for part in parts:
for short, full in SHORT_MAP.items():
if part.startswith(short):
try:
delta = int(part[len(short):])
effects_list.append({'attr': full, 'delta': delta, 'text': part})
except (ValueError, IndexError):
pass
break
# Handle stamina/savings in string format
if part.startswith('精力'):
try:
delta = int(part[2:])
effects_list.append({'attr': 'stamina', 'delta': delta})
except (ValueError, IndexError):
pass
elif part.startswith('储蓄'):
try:
delta = int(part[2:])
effects_list.append({'attr': 'savings', 'delta': delta})
except (ValueError, IndexError):
pass
elif isinstance(choice_effect, list):
effects_list = choice_effect
else:
return level_changes
XP_PER_EFFECT_POINT = 25
xp = state.get('attribute_xp', {})
for ef in effects_list:
if not isinstance(ef, dict):
continue
attr = ef.get('attr', '')
delta = ef.get('delta', 0)
if not attr or not delta:
continue
# stamina/savings are handled by LLM's stamina_change/savings_change
if attr in ('stamina', 'savings'):
continue
# 作品集厚度 = direct counter (not XP)
if attr == '作品集厚度':
count = state.get('_portfolio_count', len(state.get('portfolio', [])))
old_lvl = count
state['_portfolio_count'] = max(0, count + delta)
state['attributes'][attr] = state['_portfolio_count']
if state['_portfolio_count'] != old_lvl:
level_changes[attr] = state['_portfolio_count'] - old_lvl
continue
xp_delta = XP_PER_EFFECT_POINT * delta
old_level = xp_to_level(xp.get(attr, 0))
xp[attr] = xp.get(attr, 0) + xp_delta
new_level = xp_to_level(xp[attr])
if new_level != old_level:
level_changes[attr] = new_level - old_level
state['attribute_xp'] = xp
return level_changes
# ============================================================
TITLE_THRESHOLDS = [
{'title': '见习设计师', 'stage': '萌芽期', 'attrs': {}},
{'title': '初级设计师', 'stage': '成长期', 'attrs': {'审美判断力': 5, '执行能力': 5}},
{'title': '中级设计师', 'stage': '成长期', 'attrs': {'审美判断力': 8, '执行能力': 8, '作品集厚度': 6}},
{'title': '高级设计师', 'stage': '成熟期', 'attrs': {'审美判断力': 11, '执行能力': 10, '作品集厚度': 8, '表达能力': 8}},
{'title': '资深设计师', 'stage': '成熟期', 'attrs': {'审美判断力': 13, '执行能力': 12, '作品集厚度': 11, '表达能力': 10, '商业思维': 9}},
{'title': '设计总监', 'stage': '巅峰期', 'attrs': {'审美判断力': 15, '执行能力': 13, '作品集厚度': 13, '表达能力': 12, '商业思维': 12, '创意深度': 12}},
{'title': '创意合伙人', 'stage': '巅峰期', 'attrs': {'审美判断力': 17, '执行能力': 15, '作品集厚度': 16, '表达能力': 15, '商业思维': 15, '创意深度': 15}},
{'title': '独立设计大师', 'stage': '传奇', 'attrs': {'审美判断力': 18, '作品集厚度': 18, '表达能力': 16, '创意深度': 16}},
]
ACHIEVEMENTS = [
{'id': 'first_project', 'name': '初出茅庐', 'desc': '完成第一个设计项目', 'icon': '🌱'},
{'id': 'portfolio_10', 'name': '作品等身', 'desc': '作品集厚度达到 10', 'icon': '📦'},
{'id': 'portfolio_15', 'name': '业界标杆', 'desc': '作品集厚度达到 15', 'icon': '🏆'},
{'id': 'expression_12', 'name': '金字招牌', 'desc': '表达能力达到 12', 'icon': '🤝'},
{'id': 'expression_16', 'name': '德高望重', 'desc': '表达能力达到 16', 'icon': '👑'},
{'id': 'aesthetic_14', 'name': '审美大师', 'desc': '审美判断力达到 14', 'icon': '🎨'},
{'id': 'stamina_low', 'name': '至暗时刻', 'desc': '精力值降到 10 以下','icon': '🌑'},
{'id': 'stamina_recover','name': '涅槃重生', 'desc': '精力从低谷恢复到 70+','icon': '🔥'},
{'id': 'npc_3_related', 'name': '社交达人', 'desc': '与 3 位 NPC 建立关系', 'icon': '💬'},
{'id': 'turn_20', 'name': '十年磨一剑', 'desc': '职业生涯超过 20 回合', 'icon': '⏳'},
{'id': 'turn_50', 'name': '老设计师', 'desc': '职业生涯超过 50 回合', 'icon': '📜'},
{'id': 'all_rounder', 'name': '六边形战士', 'desc': '5项核心属性 ≥ 10', 'icon': '⭐'},
]
# ============================================================
# S1: Milestone System — 阶段目标
# ============================================================
MILESTONE_TEMPLATES = [
{
'type': 'project',
'desc_tpl': '完成"{project}"项目',
'deadline_turns': 6,
'reward': '商业+2 储蓄+5000',
'check': lambda s: s.get('current_project') and s['current_project'].get('phase') == '完成'
},
{
'type': 'promotion',
'desc_tpl': '晋升至{next_title}',
'deadline_turns': 8,
'reward': '表达+2 储蓄+3000',
'check': lambda s: s.get('title', {}).get('title', '') == s.get('_milestone_target_title', ''),
},
{
'type': 'financial',
'desc_tpl': '储蓄达到{savings_target}元',
'deadline_turns': 7,
'reward': '商业+1 储蓄+2000',
'check': lambda s: s.get('savings', 0) >= s.get('_milestone_target_savings', 0),
},
{
'type': 'networking',
'desc_tpl': '与一位NPC建立深度合作关系',
'deadline_turns': 6,
'reward': '表达+2',
'check': lambda s: any('合作' in n.get('relation', '') or '信任' in n.get('relation', '') for n in s.get('npcs', [])),
},
{
'type': 'learning',
'desc_tpl': '将{attr_name}提升至{attr_target}',
'deadline_turns': 5,
'reward': '创意+1 精力+20',
'check': lambda s: s.get('attributes', {}).get(s.get('_milestone_attr', ''), 0) >= s.get('_milestone_attr_target', 0),
},
]
def generate_milestone(state):
'''Generate a new milestone for the player.'''
import random as _random
title = state.get('title', {}).get('title', '见习设计师')
current_idx = next((i for i, t in enumerate(TITLE_THRESHOLDS) if t['title'] == title), 0)
next_title = TITLE_THRESHOLDS[min(current_idx + 1, len(TITLE_THRESHOLDS) - 1)]['title'] if current_idx < len(TITLE_THRESHOLDS) - 1 else None
if next_title and current_idx < 3:
tpl = MILESTONE_TEMPLATES[1] # promotion
target = next_title
state['_milestone_target_title'] = target
return {
'type': 'promotion', 'description': f'晋升至{target}',
'deadline_turn': state['turn_count'] + 8, 'progress': 0,
'reward': tpl['reward'], 'completed': False
}
else:
# Pick a random non-promotion milestone
tpl = _random.choice([t for t in MILESTONE_TEMPLATES if t['type'] != 'promotion'])
desc = tpl['desc_tpl']
if tpl['type'] == 'financial':
target_savings = state.get('savings', 3000) + _random.choice([5000, 8000, 12000])
desc = desc.replace('{savings_target}', str(target_savings))
state['_milestone_target_savings'] = target_savings
state['_milestone_start_savings'] = state.get('savings', 3000)
elif tpl['type'] == 'learning':
attr = _random.choice(['审美判断力', '执行能力', '商业思维', '表达能力', '创意深度'])
target = min(ATTR_CAP, state.get('attributes', {}).get(attr, 5) + _random.choice([2, 3]))
desc = desc.replace('{attr_name}', attr).replace('{attr_target}', str(target))
state['_milestone_attr'] = attr
state['_milestone_attr_target'] = target
state['_milestone_attr_start'] = state.get('attributes', {}).get(attr, 5)
else:
project_name = _random.choice(['品牌VI升级', '产品发布会设计', '年度画册', '空间导视系统', 'APP界面改版'])
desc = desc.replace('{project}', project_name)
deadline = state['turn_count'] + tpl['deadline_turns']
return {
'type': tpl['type'], 'description': desc,
'deadline_turn': deadline, 'progress': 0,
'reward': tpl['reward'], 'completed': False
}
def check_milestone(state):
'''Check milestone progress (state-driven) and completion. Returns (newly_completed, reward_str).'''
ms = state.get('milestone')
if not ms or ms.get('completed'):
return False, ''
turn = state['turn_count']
# Calculate TRUE progress based on target state, not just time
ms_type = ms.get('type')
attrs = state.get('attributes', {})
if ms_type == 'promotion':
target_title = state.get('_milestone_target_title', '')
# Progress = how many attributes meet the title threshold
target_threshold = next((t for t in TITLE_THRESHOLDS if t['title'] == target_title), None)
if target_threshold:
reqs = target_threshold['attrs']
total_reqs = len(reqs)
met_reqs = sum(1 for k, v in reqs.items() if attrs.get(k, 0) >= v)
ms['progress'] = min(99, int(met_reqs / max(1, total_reqs) * 100)) if total_reqs > 0 else 50
else:
ms['progress'] = 50
elif ms_type == 'financial':
target = state.get('_milestone_target_savings', 8000)
current = state.get('savings', 0)
start = state.get('_milestone_start_savings', max(1, current))
ms['progress'] = min(99, int((current - start) / max(1, target - start) * 100))
elif ms_type == 'learning':
attr = state.get('_milestone_attr', '')
target = state.get('_milestone_attr_target', 8)
current = attrs.get(attr, 5)
start = state.get('_milestone_attr_start', max(1, current))
ms['progress'] = min(99, int((current - start) / max(1, target - start) * 100))
elif ms_type == 'networking':
npcs = state.get('npcs', [])
related = [n for n in npcs if n.get('relation', '待剧情展开') != '待剧情展开']
target_count = 3 # "与NPC建立深度关系"
ms['progress'] = min(99, int(len(related) / target_count * 100))
elif ms_type == 'project':
proj = state.get('current_project')
if proj:
ms['progress'] = min(99, max(proj.get('quality', 0), proj.get('client_satisfaction', 0)))
else:
ms['progress'] = 0
# Check completion
for tpl in MILESTONE_TEMPLATES:
if tpl['type'] == ms_type and tpl.get('check'):
if tpl['check'](state):
ms['completed'] = True
reward = ms.get('reward', '')
apply_effect_xp(state, reward)
return True, reward
# Check if expired
if turn >= ms['deadline_turn']:
ms['completed'] = True
return True, ''
return False, ''
# ============================================================
# S2: Status Effect System — 精力状态效果
# ============================================================
def get_stamina_status(stamina):
'''Return status effect based on current stamina.'''
if stamina >= 80: return '精力充沛', None
if stamina >= 60: return '正常', None
if stamina >= 40: return '疲劳', 'penalty_small'
if stamina >= 20: return '严重疲劳', 'choices_reduced'
if stamina >= 10: return '透支', 'warning'
return '濒临崩塌', 'forced_rest'
# ============================================================
# 2. Savings Crisis System — 储蓄危机事件链
# ============================================================
def check_savings_crisis(state):
'''Check savings level and return crisis event if applicable.'''
savings = state.get('savings', 0)
crisis = state.get('_savings_crisis_level', 0)
turn = state.get('turn_count', 0)
events = []
if savings <= 0 and crisis < 1:
state['_savings_crisis_level'] = 1
events.append({
'level': 1, 'type': 'survival',
'message': '储蓄见底!下个月房租都成问题了。',
'attr_penalty': {'精力值': -10}
})
elif savings <= -3000 and crisis < 2:
state['_savings_crisis_level'] = 2
events.append({
'level': 2, 'type': 'debt',
'message': '你已经欠了两个月的房租。房东发来了最后通牒。',
'attr_penalty': {'审美判断力': -1, '精力值': -15}
})
elif savings <= -8000 and crisis < 3:
state['_savings_crisis_level'] = 3
events.append({
'level': 3, 'type': 'bankruptcy',
'message': '财务状况彻底崩溃。你需要做出艰难的决定。',
'attr_penalty': {'精力值': -20}
})
# Recovery: leaving crisis
if savings > 2000 and crisis > 0:
state['_savings_crisis_level'] = 0
events.append({
'level': 0, 'type': 'recovery',
'message': '财务状况终于开始好转,你松了一口气。',
'attr_penalty': {}
})
return events[-1] if events else None
# ============================================================
# 3. Project Decision System — 项目四阶段决策
# ============================================================
PROJECT_PHASES = ['竞标', '执行', '改稿', '交付']
def advance_project_phase(state):
'''Auto-advance project phase and generate decision prompt.'''
proj = state.get('current_project')
if not proj or proj.get('phase') == '完成':
return None
turn = state.get('turn_count', 0)
start = proj.get('start_turn', turn)
elapsed = turn - start
deadline = proj.get('deadline_turns', 6)
progress = min(1.0, elapsed / deadline)
phase_hints = {
'竞标': '这个项目刚出现在你的视野里。需要决定要不要接:预算多少?甲方靠谱吗?精力够不够?',
'执行': '项目正在推进中。是保守执行还是冒险突破?要不要加班赶进度?',
'改稿': '甲方提出了修改意见。是大改还是沟通协商?每一次改稿都在消耗精力和时间。',
'交付': '项目接近尾声。提前交稿还是打磨到极致?客户满意度 vs 你的完美主义。',
}
# Auto-advance based on progress
phases_progressed = False
if progress >= 0.1 and proj.get('phase') == '竞标':
proj['phase'] = '执行'; phases_progressed = True
if progress >= 0.5 and proj.get('phase') == '执行':
proj['phase'] = '改稿'; phases_progressed = True
if progress >= 0.7 and proj.get('phase') == '改稿':
proj['phase'] = '交付'; phases_progressed = True
if progress >= 1.0 and proj.get('phase') in ('交付', '改稿'):
proj['phase'] = '完成'; phases_progressed = True
# Completion reward
state['savings'] = state.get('savings', 0) + proj.get('budget', 5000)
xp = state.get('attribute_xp', {})
xp['商业思维'] = xp.get('商业思维', 0) + 200
state['attribute_xp'] = xp
# 作品集厚度 = direct counter
count = state.get('_portfolio_count', len(state.get('portfolio', [])))
state['_portfolio_count'] = min(20, count + 1)
state['attributes'] = xp_to_attrs(state)
# Record to portfolio + cooldown
add_portfolio_entry(state, proj)
state['_project_cooldown'] = random.randint(4, 8)
state.pop('current_project', None)
if phases_progressed:
state['_project_phase_changed'] = proj.get('phase', '')
return phase_hints.get(proj.get('phase', ''), '') if phases_progressed else None
def add_portfolio_entry(state, proj):
'''Record a completed project into the portfolio list.'''
if 'portfolio' not in state:
state['portfolio'] = []
turn = state.get('turn_count', 0)
# Extract last narrative line that mentions this project
last_entry = state['story_log'][-1] if state['story_log'] else {}
summary = ''
if last_entry.get('event_tag') == '项目推进':
summary = (last_entry.get('narrative', '') or '')[:100]
entry = {
'name': proj.get('name', '设计项目'),
'time': get_game_date(state),
'turn': turn,
'client': proj.get('client', ''),
'budget': proj.get('budget', 0),
'quality': proj.get('quality', 50),
'summary': summary,
}
state['portfolio'].append(entry)
# ============================================================
# 4. Narrative Arc System — 跨回合叙事弧
# ============================================================
NARRATIVE_ARCS = [
{
'id': 'job_hunt', 'name': '求职之旅',
'turns': 3, 'phases': ['听说机会','准备面试','结果抉择'],
'trigger': lambda s: '投简历' in str(s.get('story_log', [{}])[-1:]) or '找工作' in str(s.get('story_log', [{}])[-1:]),
},
{
'id': 'big_project', 'name': '大项目攻防',
'turns': 4, 'phases': ['竞标','执行','改稿','交付'],
'trigger': lambda s: s.get('current_project') and '竞标' in s.get('current_project',{}).get('phase',''),
},
{
'id': 'burnout_recovery', 'name': '倦怠与重生',
'turns': 3, 'phases': ['身心俱疲','重新思考','找到节奏'],
'trigger': lambda s: s.get('stamina', 80) < 15,
},
{
'id': 'industry_event', 'name': '行业盛会',
'turns': 2, 'phases': ['收到邀请','参会抉择'],
'trigger': lambda s: '行业事件' in str(s.get('story_log', [{}])[-1].get('event_tag','')),
},
]
def detect_and_start_arc(state):
'''Check if a narrative arc should begin.'''
current_arc = state.get('_narrative_arc')
if current_arc:
phase_idx = current_arc.get('phase_idx', 0)
phases = current_arc.get('phases', [])
if phase_idx < len(phases) - 1:
current_arc['phase_idx'] = phase_idx + 1
current_arc['turn_in_arc'] = (current_arc.get('turn_in_arc', 0) + 1)
return f'叙事弧「{current_arc["name"]}」第{phase_idx+2}阶段:{phases[phase_idx+1]}'
else:
state['_narrative_arc'] = None
return f'叙事弧「{current_arc["name"]}」完结'
for arc in NARRATIVE_ARCS:
if arc['trigger'](state):
import random as _random
if _random.random() < 0.4: # 40% chance to start
state['_narrative_arc'] = {
'id': arc['id'], 'name': arc['name'],
'phases': arc['phases'], 'phase_idx': 0, 'turn_in_arc': 1
}
return f'叙事弧开始:「{arc["name"]}」第1阶段:{arc["phases"][0]}'
return None
# ============================================================
# 6. Career Stage Challenges — 职业阶段挑战
# ============================================================
CAREER_CHALLENGES = [
{'turn': 12, 'name': '第一次倦怠', 'message': '入行快两年了。日复一日的改稿让你开始怀疑:这就是我想要的设计师生涯吗?',
'effect': {'精力值': -20}},
{'turn': 24, 'name': 'AI冲击', 'message': 'AI设计工具开始席卷行业。客户开始问"你们能用AI做吗?"一些低端单子被替代了。',
'effect': {}},
{'turn': 36, 'name': '后浪来袭', 'message': '隔壁组新来的95后设计师,作品直接上了站酷首页。你感受到了代际压力。',
'effect': {}},
{'turn': 48, 'name': '天花板', 'message': '你在这个位置上已经很久了。往上走需要的不只是能力——还需要机会、运气、和站队。',
'effect': {}},
{'turn': 60, 'name': '中年转型', 'message': '十年设计师生涯。是继续深耕,还是转型管理?或者——创业?',
'effect': {}},
]
def check_career_challenge(state):
'''Check if current turn triggers a career challenge.'''
turn = state.get('turn_count', 0)
triggered = state.get('_challenges_triggered', [])
for ch in CAREER_CHALLENGES:
if turn >= ch['turn'] and ch['turn'] not in triggered:
triggered.append(ch['turn'])
state['_challenges_triggered'] = triggered
# Apply effects
for attr, delta in ch.get('effect', {}).items():
if attr == '精力值':
state['stamina'] = max(0, state.get('stamina', 80) + delta)
else:
xp = state.get('attribute_xp', {})
xp[attr] = xp.get(attr, 0) + 50 * delta
state['attribute_xp'] = xp
state['attributes'] = xp_to_attrs(state)
return ch
return None
# ============================================================
# 7. Trait-Driven Option Filtering — Traits影响选项池
# ============================================================
TRAIT_PREFERRED_OPTIONS = {
'学院派': '你偏爱创新探索型的选择方案',
'执行力基因': '你偏好高效直接的选择方案',
'商业嗅觉': '你自然倾向于考虑商业回报的选择',
'信息敏感': '你更容易注意到行业信息和人脉机会',
'独狼基因': '你偏向独立解决问题的方案,而非求助他人',
'工艺基因': '你对材料和落地方案有天然的偏好',
}
def get_trait_context(state):
'''Return LLM context string based on player trait.'''
trait = state.get('trait', {})
trait_name = trait.get('name', '')
if trait_name and trait_name in TRAIT_PREFERRED_OPTIONS:
return TRAIT_PREFERRED_OPTIONS[trait_name]
return ''
# ============================================================
# Economy — 月薪月开销(1回合=1周,约4回合结算1次月薪)
# ============================================================
MONTHLY_BASE_EXPENSE = 2500 # 基础月开销
SALARY_BY_TITLE = {
'见习设计师': 3500, '初级设计师': 5000, '中级设计师': 8000,
'高级设计师': 12000, '资深设计师': 18000,
'设计总监': 25000, '创意合伙人': 35000, '独立设计大师': 50000,
}
ECONOMY_INTERVAL = 4 # 每4回合(约1个月)结算一次