mcshengxia commited on
Commit
820bd65
·
1 Parent(s): 78d6422

feat: per-user data isolation, fix model name, clean gitignore

Browse files

- core/session.py: ContextVar-based user isolation for multi-user HF Spaces
- Player/World data stored per-user in data/users/{hash}/
- Session ID generated from request headers or random
- Key handlers (dream, explore, rest, quest) set user context
- Fix hero tag: MiniCPM5-1B -> MiniCPM4.1-8B (matches config)
- Update .gitignore: exclude data/saves/, player.json, active_slot.txt
- Fix test_player.py: update mock patches for new session functions

Files changed (5) hide show
  1. .gitignore +3 -0
  2. core/player.py +20 -16
  3. core/session.py +71 -0
  4. tests/test_player.py +7 -7
  5. ui/main.py +28 -11
.gitignore CHANGED
@@ -21,6 +21,9 @@ env/
21
  # Data (runtime-generated)
22
  data/world.json
23
  data/dreams.json
 
 
 
24
 
25
  # OS
26
  .DS_Store
 
21
  # Data (runtime-generated)
22
  data/world.json
23
  data/dreams.json
24
+ data/saves/
25
+ data/player.json
26
+ data/active_slot.txt
27
 
28
  # OS
29
  .DS_Store
core/player.py CHANGED
@@ -183,55 +183,59 @@ def save_player(player: Player):
183
 
184
 
185
  # ============================================================
186
- # Save Slot System — Multi-user / Multi-save support
187
  # ============================================================
188
 
189
- SLOTS_DIR = config.DATA_DIR / "saves"
190
- ACTIVE_SLOT_FILE = config.DATA_DIR / "active_slot.txt"
 
 
 
 
 
 
 
 
191
 
192
 
193
  def _slot_path(slot: int) -> Path:
194
- """Return the player.json path for a given save slot."""
195
- return SLOTS_DIR / f"slot_{slot}" / "player.json"
196
 
197
 
198
  def get_slot_dir(slot: int = None) -> Path:
199
- """Return the directory for a save slot (creates if needed)."""
200
  if slot is None:
201
  slot = get_active_slot()
202
- d = SLOTS_DIR / f"slot_{slot}"
203
  d.mkdir(parents=True, exist_ok=True)
204
  return d
205
 
206
 
207
  def get_current_world_file() -> Path:
208
- """Return the world.json path for the active save slot."""
209
  return get_slot_dir() / "world.json"
210
 
211
 
212
  def get_current_dreams_file() -> Path:
213
- """Return the dreams.json path for the active save slot."""
214
  return get_slot_dir() / "dreams.json"
215
 
216
 
217
  def _slot_meta_path() -> Path:
218
- return SLOTS_DIR / "slots_meta.json"
219
 
220
 
221
  def get_active_slot() -> int:
222
- """Get the currently active save slot number (default 0)."""
223
- if ACTIVE_SLOT_FILE.exists():
224
  try:
225
- return int(ACTIVE_SLOT_FILE.read_text(encoding="utf-8").strip())
226
  except (ValueError, TypeError):
227
  pass
228
  return 0
229
 
230
 
231
  def set_active_slot(slot: int):
232
- """Set the active save slot."""
233
- ACTIVE_SLOT_FILE.parent.mkdir(parents=True, exist_ok=True)
234
- ACTIVE_SLOT_FILE.write_text(str(slot), encoding="utf-8")
235
 
236
 
237
  def _update_slot_meta(slot: int, player: Player):
 
183
 
184
 
185
  # ============================================================
186
+ # Save Slot System — Per-user isolation via session
187
  # ============================================================
188
 
189
+ def _get_slots_dir() -> Path:
190
+ """Get the saves directory for the current user session."""
191
+ from core.session import get_user_saves_dir
192
+ return get_user_saves_dir()
193
+
194
+
195
+ def _get_active_slot_file() -> Path:
196
+ """Get the active slot file for the current user session."""
197
+ from core.session import get_user_active_slot_file
198
+ return get_user_active_slot_file()
199
 
200
 
201
  def _slot_path(slot: int) -> Path:
202
+ return _get_slots_dir() / f"slot_{slot}" / "player.json"
 
203
 
204
 
205
  def get_slot_dir(slot: int = None) -> Path:
 
206
  if slot is None:
207
  slot = get_active_slot()
208
+ d = _get_slots_dir() / f"slot_{slot}"
209
  d.mkdir(parents=True, exist_ok=True)
210
  return d
211
 
212
 
213
  def get_current_world_file() -> Path:
 
214
  return get_slot_dir() / "world.json"
215
 
216
 
217
  def get_current_dreams_file() -> Path:
 
218
  return get_slot_dir() / "dreams.json"
219
 
220
 
221
  def _slot_meta_path() -> Path:
222
+ return _get_slots_dir() / "slots_meta.json"
223
 
224
 
225
  def get_active_slot() -> int:
226
+ path = _get_active_slot_file()
227
+ if path.exists():
228
  try:
229
+ return int(path.read_text(encoding="utf-8").strip())
230
  except (ValueError, TypeError):
231
  pass
232
  return 0
233
 
234
 
235
  def set_active_slot(slot: int):
236
+ path = _get_active_slot_file()
237
+ path.parent.mkdir(parents=True, exist_ok=True)
238
+ path.write_text(str(slot), encoding="utf-8")
239
 
240
 
241
  def _update_slot_meta(slot: int, player: Player):
core/session.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session management — per-user data isolation for multi-user HF Spaces."""
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+ from contextvars import ContextVar
7
+ from functools import wraps
8
+
9
+ import config
10
+
11
+ USERS_DIR = config.DATA_DIR / "users"
12
+ _current_user_id: ContextVar[str] = ContextVar("current_user_id", default="default")
13
+
14
+
15
+ def set_current_user(user_id: str):
16
+ _current_user_id.set(user_id)
17
+
18
+
19
+ def get_current_user_id() -> str:
20
+ return _current_user_id.get()
21
+
22
+
23
+ def with_user(fn):
24
+ """Decorator: extracts session_id from inputs and sets user context."""
25
+ @wraps(fn)
26
+ def wrapper(*args, **kwargs):
27
+ # Find session_id in args (usually last positional arg)
28
+ sid = "default"
29
+ for a in reversed(args):
30
+ if isinstance(a, str) and len(a) == 16 and all(c in '0123456789abcdef' for c in a):
31
+ sid = a
32
+ break
33
+ set_current_user(sid)
34
+ # Remove session_id from args for the actual function
35
+ new_args = [a for a in args if a is not sid]
36
+ return fn(*new_args, **kwargs)
37
+ return wrapper
38
+
39
+
40
+ def get_user_dir(user_id: str = None) -> Path:
41
+ uid = user_id or get_current_user_id()
42
+ safe_id = hashlib.md5(uid.encode()).hexdigest()[:12]
43
+ user_dir = USERS_DIR / safe_id
44
+ user_dir.mkdir(parents=True, exist_ok=True)
45
+ (user_dir / "saves").mkdir(exist_ok=True)
46
+ return user_dir
47
+
48
+
49
+ def get_user_world_file(user_id: str = None) -> Path:
50
+ return get_user_dir(user_id) / "world.json"
51
+
52
+
53
+ def get_user_saves_dir(user_id: str = None) -> Path:
54
+ return get_user_dir(user_id) / "saves"
55
+
56
+
57
+ def get_user_active_slot_file(user_id: str = None) -> Path:
58
+ return get_user_dir(user_id) / "active_slot.txt"
59
+
60
+
61
+ def generate_session_id(request=None) -> str:
62
+ """Generate a unique session ID from Gradio request."""
63
+ if request is not None:
64
+ try:
65
+ ip = getattr(request.client, 'host', '') if hasattr(request, 'client') else ''
66
+ ua = request.headers.get("user-agent", "") if hasattr(request, 'headers') else ""
67
+ if ip or ua:
68
+ return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
69
+ except Exception:
70
+ pass
71
+ return hashlib.md5(os.urandom(16)).hexdigest()[:16]
tests/test_player.py CHANGED
@@ -7,7 +7,7 @@ from unittest.mock import patch
7
  from core.player import (
8
  Player, load_player, save_player, get_active_slot, set_active_slot,
9
  list_save_slots, switch_slot, delete_slot, rename_player,
10
- SLOTS_DIR, ACTIVE_SLOT_FILE, _slot_path,
11
  )
12
 
13
 
@@ -160,8 +160,8 @@ def test_save_slot_persistence():
160
  """Test save/load with slot system."""
161
  with tempfile.TemporaryDirectory() as tmpdir:
162
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
163
- patch("core.player.SLOTS_DIR", Path(tmpdir) / "saves"), \
164
- patch("core.player.ACTIVE_SLOT_FILE", Path(tmpdir) / "active_slot.txt"):
165
 
166
  # Save to slot 0
167
  p = Player()
@@ -200,8 +200,8 @@ def test_save_slot_delete():
200
  """Test deleting a save slot."""
201
  with tempfile.TemporaryDirectory() as tmpdir:
202
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
203
- patch("core.player.SLOTS_DIR", Path(tmpdir) / "saves"), \
204
- patch("core.player.ACTIVE_SLOT_FILE", Path(tmpdir) / "active_slot.txt"):
205
 
206
  p = Player()
207
  p.name = "待删除"
@@ -219,8 +219,8 @@ def test_rename_player():
219
  """Test renaming the current player."""
220
  with tempfile.TemporaryDirectory() as tmpdir:
221
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
222
- patch("core.player.SLOTS_DIR", Path(tmpdir) / "saves"), \
223
- patch("core.player.ACTIVE_SLOT_FILE", Path(tmpdir) / "active_slot.txt"):
224
 
225
  save_player(Player())
226
  rename_player("新名字")
 
7
  from core.player import (
8
  Player, load_player, save_player, get_active_slot, set_active_slot,
9
  list_save_slots, switch_slot, delete_slot, rename_player,
10
+ _slot_path, _get_slots_dir, _get_active_slot_file,
11
  )
12
 
13
 
 
160
  """Test save/load with slot system."""
161
  with tempfile.TemporaryDirectory() as tmpdir:
162
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
163
+ patch("core.player._get_slots_dir", lambda: Path(tmpdir) / "saves"), \
164
+ patch("core.player._get_active_slot_file", lambda: Path(tmpdir) / "active_slot.txt"):
165
 
166
  # Save to slot 0
167
  p = Player()
 
200
  """Test deleting a save slot."""
201
  with tempfile.TemporaryDirectory() as tmpdir:
202
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
203
+ patch("core.player._get_slots_dir", lambda: Path(tmpdir) / "saves"), \
204
+ patch("core.player._get_active_slot_file", lambda: Path(tmpdir) / "active_slot.txt"):
205
 
206
  p = Player()
207
  p.name = "待删除"
 
219
  """Test renaming the current player."""
220
  with tempfile.TemporaryDirectory() as tmpdir:
221
  with patch("core.player.PLAYER_FILE", Path(tmpdir) / "player.json"), \
222
+ patch("core.player._get_slots_dir", lambda: Path(tmpdir) / "saves"), \
223
+ patch("core.player._get_active_slot_file", lambda: Path(tmpdir) / "active_slot.txt"):
224
 
225
  save_player(Player())
226
  rename_player("新名字")
ui/main.py CHANGED
@@ -12,6 +12,7 @@ from ui.explorer_tab import _get_location_choices, _render_location, explore_loc
12
  from ui.journal_tab import _render_journal, get_quest_choices, complete_quest_action, find_quest_id_by_name
13
  from ui.player_tab import _render_player_sheet, switch_save_slot, create_character
14
  from core.player import load_player, list_save_slots, get_active_slot
 
15
 
16
 
17
  # ============================================================
@@ -43,7 +44,7 @@ def _hero_html():
43
  {t('hero_subtitle')}
44
  </p>
45
  <div style="display:flex;justify-content:center;gap:16px;">
46
- <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">MiniCPM5-1B</span>
47
  <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">llama.cpp</span>
48
  <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">Local-First</span>
49
  </div>
@@ -113,6 +114,9 @@ def create_app():
113
  initial_slot = get_active_slot()
114
 
115
  with gr.Blocks(title="DreamLand") as app:
 
 
 
116
  # Hero
117
  gr.HTML(_hero_html())
118
 
@@ -218,7 +222,8 @@ def create_app():
218
  # ===== EVENT WIRING =====
219
 
220
  # Slot selection
221
- def _on_slot_click(idx):
 
222
  slots = list_save_slots()
223
  s = next((s for s in slots if s["slot"] == idx), None)
224
  is_new = not s or s["level"] == 0
@@ -234,12 +239,14 @@ def create_app():
234
 
235
  for i, btn in enumerate(slot_btns):
236
  btn.click(
237
- fn=lambda idx=i: _on_slot_click(idx),
 
238
  outputs=[slot_panel, action_btn, selected_slot],
239
  )
240
 
241
  # Action button (create or enter)
242
- def _on_action(slot_idx, name):
 
243
  slots = list_save_slots()
244
  s = next((s for s in slots if s["slot"] == slot_idx), None)
245
  is_new = not s or s["level"] == 0
@@ -258,18 +265,19 @@ def create_app():
258
 
259
  action_btn.click(
260
  fn=_on_action,
261
- inputs=[selected_slot, name_input],
262
  outputs=[slot_panel, action_status, player_display],
263
  )
264
 
265
  # Dream submission
266
- def _process_and_refresh(dream_text, img):
 
267
  results = process_dream(dream_text, img)
268
  return (*results, _render_player_sheet())
269
 
270
  submit_btn.click(
271
  fn=_process_and_refresh,
272
- inputs=[dream_input, image_input],
273
  outputs=[
274
  status_display, dream_text_state,
275
  loc_name, loc_desc,
@@ -288,19 +296,28 @@ def create_app():
288
  map_refresh.click(fn=_generate_map_html, outputs=[map_display])
289
 
290
  # Explorer
 
 
 
 
 
 
 
 
291
  exp_dropdown.change(fn=_render_location, inputs=[exp_dropdown], outputs=[exp_display])
292
- explore_btn.click(fn=explore_location, inputs=[exp_dropdown], outputs=[exp_result, exp_display])
293
- rest_btn.click(fn=rest_action, inputs=[exp_dropdown], outputs=[exp_result, exp_display])
294
 
295
  # Journal
296
- def _complete_quest(quest_name):
 
297
  if not quest_name:
298
  return t("journal_select_quest"), _render_journal()
299
  qid = find_quest_id_by_name(quest_name)
300
  msg = complete_quest_action(qid)
301
  return msg, _render_journal()
302
 
303
- complete_btn.click(fn=_complete_quest, inputs=[quest_dropdown], outputs=[journal_result, journal_display])
304
  journal_refresh.click(fn=_render_journal, outputs=[journal_display])
305
 
306
  # Player refresh
 
12
  from ui.journal_tab import _render_journal, get_quest_choices, complete_quest_action, find_quest_id_by_name
13
  from ui.player_tab import _render_player_sheet, switch_save_slot, create_character
14
  from core.player import load_player, list_save_slots, get_active_slot
15
+ from core.session import set_current_user, generate_session_id
16
 
17
 
18
  # ============================================================
 
44
  {t('hero_subtitle')}
45
  </p>
46
  <div style="display:flex;justify-content:center;gap:16px;">
47
+ <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">MiniCPM4.1-8B</span>
48
  <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">llama.cpp</span>
49
  <span style="padding:4px 14px;border:1px solid #1e1e36;border-radius:20px;font-size:0.72rem;color:#7878a0;">Local-First</span>
50
  </div>
 
114
  initial_slot = get_active_slot()
115
 
116
  with gr.Blocks(title="DreamLand") as app:
117
+ # Session state for per-user isolation
118
+ session_id = gr.State(generate_session_id())
119
+
120
  # Hero
121
  gr.HTML(_hero_html())
122
 
 
222
  # ===== EVENT WIRING =====
223
 
224
  # Slot selection
225
+ def _on_slot_click(idx, sid):
226
+ set_current_user(sid)
227
  slots = list_save_slots()
228
  s = next((s for s in slots if s["slot"] == idx), None)
229
  is_new = not s or s["level"] == 0
 
239
 
240
  for i, btn in enumerate(slot_btns):
241
  btn.click(
242
+ fn=lambda idx=i, sid=session_id: _on_slot_click(idx, sid),
243
+ inputs=[session_id],
244
  outputs=[slot_panel, action_btn, selected_slot],
245
  )
246
 
247
  # Action button (create or enter)
248
+ def _on_action(slot_idx, name, sid):
249
+ set_current_user(sid)
250
  slots = list_save_slots()
251
  s = next((s for s in slots if s["slot"] == slot_idx), None)
252
  is_new = not s or s["level"] == 0
 
265
 
266
  action_btn.click(
267
  fn=_on_action,
268
+ inputs=[selected_slot, name_input, session_id],
269
  outputs=[slot_panel, action_status, player_display],
270
  )
271
 
272
  # Dream submission
273
+ def _process_and_refresh(dream_text, img, sid):
274
+ set_current_user(sid)
275
  results = process_dream(dream_text, img)
276
  return (*results, _render_player_sheet())
277
 
278
  submit_btn.click(
279
  fn=_process_and_refresh,
280
+ inputs=[dream_input, image_input, session_id],
281
  outputs=[
282
  status_display, dream_text_state,
283
  loc_name, loc_desc,
 
296
  map_refresh.click(fn=_generate_map_html, outputs=[map_display])
297
 
298
  # Explorer
299
+ def _explore(loc, sid):
300
+ set_current_user(sid)
301
+ return explore_location(loc)
302
+
303
+ def _rest(loc, sid):
304
+ set_current_user(sid)
305
+ return rest_action(loc)
306
+
307
  exp_dropdown.change(fn=_render_location, inputs=[exp_dropdown], outputs=[exp_display])
308
+ explore_btn.click(fn=_explore, inputs=[exp_dropdown, session_id], outputs=[exp_result, exp_display])
309
+ rest_btn.click(fn=_rest, inputs=[exp_dropdown, session_id], outputs=[exp_result, exp_display])
310
 
311
  # Journal
312
+ def _complete_quest(quest_name, sid):
313
+ set_current_user(sid)
314
  if not quest_name:
315
  return t("journal_select_quest"), _render_journal()
316
  qid = find_quest_id_by_name(quest_name)
317
  msg = complete_quest_action(qid)
318
  return msg, _render_journal()
319
 
320
+ complete_btn.click(fn=_complete_quest, inputs=[quest_dropdown, session_id], outputs=[journal_result, journal_display])
321
  journal_refresh.click(fn=_render_journal, outputs=[journal_display])
322
 
323
  # Player refresh