--- a/server/world/zone_types.py +++ b/server/world/zone_types.py @@ -68,9 +68,15 @@ respawn_zone_id: str respawn_x: float respawn_y: float + max_level: int = 100 waypoints: Dict[str, Waypoint] = field(default_factory=dict) portals: Dict[str, ZonePortal] = field(default_factory=dict) npc_ids: List[str] = field(default_factory=list) + + @property + def display_name(self) -> str: + """Alias for name attribute to maintain compatibility.""" + return self.name @dataclass(slots=True) --- a/server/world/zone_catalog.py +++ b/server/world/zone_catalog.py @@ -87,6 +87,16 @@ target_y=150.0, min_level=20, ), + "portal_sanctuary_to_hideout": ZonePortal( + portal_id="portal_sanctuary_to_hideout", + name="Lối Vào: Tiên Phủ Động Thiên", + source_zone_id="zone_boundless_sanctuary", + source_x=380.0, + source_y=0.0, + target_zone_id="zone_player_hideout", + target_x=0.0, + target_y=0.0, + min_level=1, }, npc_ids=[ "npc_bach_hieu_sinh", @@ -94,6 +104,50 @@ "npc_thiet_tuong_au_da_tu", "npc_chan_vo_truyen_thua", ], + ) + ) + + # 1b. Sanctuary: Player Hideout (Tiên Phủ Động Thiên) + engine.add_zone( + ZoneDefinition( + zone_id="zone_player_hideout", + name="Tiên Phủ Động Thiên (Hideout)", + zone_type=ZoneType.SANCTUARY, + environment=ZoneEnvironment.NORMAL, + min_level=1, + max_players=5000, + bounds_width=800.0, + bounds_height=800.0, + default_spawn_x=0.0, + default_spawn_y=0.0, + respawn_zone_id="zone_player_hideout", + respawn_x=0.0, + respawn_y=0.0, + max_level=100, + waypoints={ + "wp_hideout_central": Waypoint( + waypoint_id="wp_hideout_central", + name="Trụ Đá Thần Hành: Tiên Phủ Động Thiên", + zone_id="zone_player_hideout", + x=0.0, + y=0.0, + is_unlocked_by_default=True, + ) + }, + portals={ + "portal_hideout_to_sanctuary": ZonePortal( + portal_id="portal_hideout_to_sanctuary", + name="Lối Ra: Doanh Trại Bến Lưu Đày", + source_zone_id="zone_player_hideout", + source_x=0.0, + source_y=-350.0, + target_zone_id="zone_boundless_sanctuary", + target_x=360.0, + target_y=0.0, + min_level=1, + ) + }, + npc_ids=[], ) ) --- a/server/world/zone_engine.py +++ b/server/world/zone_engine.py @@ -86,9 +86,6 @@ raise KeyError(f"Zone '{zone_id}' not found in registry.") return self.zones[zone_id] - def get_zone_definition(self, zone_id: str) -> ZoneDefinition: - """Alias for get_zone for backward compatibility.""" - return self.get_zone(zone_id) def get_zone_definition(self, zone_id: str) -> Optional[ZoneDefinition]: return self.zones.get(zone_id) @@ -108,8 +105,6 @@ PoE2 Rule: Safe Havens (Sanctuary Hub and Player Hideout) prohibit hostile mobs. Only OPEN_WORLD, DUNGEON_INSTANCE, and SECRET_CHAMBER allow hostile encounters. """ - if zone_id == "zone_player_hideout": - return False zone = self.zones.get(zone_id) if zone is None: return False --- a/server/world/game_design_matrix_seeder.py +++ b/server/world/game_design_matrix_seeder.py @@ -287,6 +287,7 @@ def _resolve_zone_act(zone_id: str) -> str: zone_act_map = { "zone_boundless_sanctuary": "ACT_I_HUNGER", + "zone_player_hideout": "ACT_I_HUNGER", "zone_tang_kiem_nhai": "ACT_I_HUNGER", "zone_ancient_sword_barrow": "ACT_II_MIASMA", "zone_boundless_sandstorm": "ACT_III_CRUCIBLE", --- a/tests/unit/test_zone_monster_spawning_rules.py +++ b/tests/unit/test_zone_monster_spawning_rules.py @@ -25,6 +25,28 @@ sanctuary = zone_engine.get_zone("zone_boundless_sanctuary") assert sanctuary is not None assert sanctuary.zone_type == ZoneType.SANCTUARY + + def test_hideout_zone_registration_and_metadata(self, zone_engine: ZoneEngine) -> None: + """Verifies that Player Hideout is canonically registered as SANCTUARY with full metadata.""" + hideout = zone_engine.get_zone("zone_player_hideout") + assert hideout is not None + assert hideout.zone_type == ZoneType.SANCTUARY + assert hideout.name == "Tiên Phủ Động Thiên (Hideout)" + assert hideout.display_name == "Tiên Phủ Động Thiên (Hideout)" + assert hideout.min_level == 1 + assert hideout.max_level == 100 + assert "wp_hideout_central" in hideout.waypoints + assert "portal_hideout_to_sanctuary" in hideout.portals + + def test_hideout_player_spawning_and_spatial_grid(self, zone_engine: ZoneEngine) -> None: + """Verifies that spawning a player in hideout initializes spatial grid without KeyError.""" + player_id = "hero_hideout_occupant" + loc = zone_engine.spawn_player(player_id, "zone_player_hideout") + assert loc.zone_id == "zone_player_hideout" + assert loc.x == 0.0 + assert loc.y == 0.0 + assert "zone_player_hideout" in zone_engine.zone_spatial_grids + assert zone_engine.zone_spatial_grids["zone_player_hideout"] is not None def test_outer_exploration_zones_are_open_world(self, zone_engine: ZoneEngine) -> None: """Verifies that wilderness maps are OPEN_WORLD where monsters roam.""" @@ -55,6 +77,14 @@ assert can_spawn is False assert "An Toàn" in reason or "Sanctuary" in reason + # Hostile monster in Hideout -> REJECTED + can_hideout, h_reason = zone_engine.validate_monster_spawn( + zone_id="zone_player_hideout", + is_dummy=False + ) + assert can_hideout is False + assert "zone_player_hideout" in h_reason + # Training dummy in Sanctuary / Hideout -> ALLOWED can_dummy, _ = zone_engine.validate_monster_spawn( zone_id="zone_boundless_sanctuary", @@ -62,12 +92,43 @@ ) assert can_dummy is True + can_h_dummy, _ = zone_engine.validate_monster_spawn( + zone_id="zone_player_hideout", + is_dummy=True + ) + assert can_h_dummy is True + # Hostile monster in Outer Map -> ALLOWED can_wild, _ = zone_engine.validate_monster_spawn( zone_id="zone_tang_kiem_nhai", is_dummy=False ) assert can_wild is True + + def test_portal_traversal_sanctuary_hideout_roundtrip(self, zone_engine: ZoneEngine) -> None: + """Verifies portal traversal between sanctuary and hideout.""" + player_id = "wayfarer_1" + zone_engine.spawn_player(player_id, "zone_boundless_sanctuary") + ok, dest, _ = zone_engine.traverse_portal(player_id, "portal_sanctuary_to_hideout", player_level=1) + assert ok is True + assert dest == "zone_player_hideout" + + ok_back, dest_back, _ = zone_engine.traverse_portal(player_id, "portal_hideout_to_sanctuary", player_level=1) + assert ok_back is True + assert dest_back == "zone_boundless_sanctuary" + + def test_clean_get_zone_definition(self, zone_engine: ZoneEngine) -> None: + """Verifies get_zone_definition works cleanly without duplicate method declaration.""" + hideout = zone_engine.get_zone_definition("zone_player_hideout") + assert hideout is not None + assert hideout.zone_id == "zone_player_hideout" + + sanctuary = zone_engine.get_zone_definition("zone_boundless_sanctuary") + assert sanctuary is not None + assert sanctuary.zone_id == "zone_boundless_sanctuary" + + missing = zone_engine.get_zone_definition("non_existent_zone_id") + assert missing is None def test_client_monster_system_js_no_hostile_mobs_in_hideout(self) -> None: """