Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions data/bomb/emitter/internal/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,30 @@

class BombEmitter:
@classmethod
def get_installed_events(cls, bomb: InstalledBomb) -> list[Event]:
def get_installed_events(cls, owner_id: str, bomb: InstalledBomb) -> list[Event]:
from data.event import InternalEvent
from data.payload import IdDataPayload

return [
Event(
event_name=InternalEvent.INSTALLED_BOMB,
payload=IdDataPayload(
id=bomb.cur_id,
id=owner_id,
data=bomb,
),
)
]

@classmethod
def get_draw_events(cls, bomb: InstalledBomb) -> list[Event]:
def get_draw_events(cls, owner_id: str, bomb: InstalledBomb) -> list[Event]:
from data.event import TriggerEvent
from data.payload import IdDataPayload

return [
Event(
event_name=TriggerEvent.DRAW_BOARD,
payload=IdDataPayload(
id=bomb.cur_id,
id=owner_id,
data=bomb,
),
)
Expand Down
3 changes: 2 additions & 1 deletion data/bomb/internal/installed_bomb.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from core.dataobj import DataObj
from data.board import Point
from data.cursor_board import Color
from datetime import datetime


class InstalledBomb(DataObj):
cur_id: str
color: Color
position: Point
explosion_range: int
active_at: datetime
Expand Down
2 changes: 1 addition & 1 deletion docs/Feature/[FT-002] 커서 이동.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## 시나리오
1. 사용자가 특정 위치로 이동을 요청한다.
2. cursor 위치가 변경되고 점수가 1 증가한다.
3. 변경된 위치 기준으로 새로운 tile 및 cursor 정보가 전달된다.
3. 변경된 위치 기준으로 새로운 tile 및 bomb, cursor 정보가 전달된다.

## 비즈니스 규칙
- opened-tile로만 이동 가능하다.
Expand Down
2 changes: 1 addition & 1 deletion docs/Feature/[FT-005] 시야 설정.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## 시나리오

1. 사용자가 시야 크기(width, height)를 설정한다.
2. 변경된 시야 범위에 따라 tile 및 cursor 정보가 업데이트된다.
2. 변경된 시야 범위에 따라 tile 및 bomb, cursor 정보가 업데이트된다.

## 비즈니스 규칙

Expand Down
3 changes: 3 additions & 0 deletions docs/Other/API_spec/WebSocket/Client/INSTALL-BOMB.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@
<!-- 색 관련은 만들어지지 않음 -->
5. `?` 발행
-> 타일의 색 변경

**Note:**
- `BOMB-POSITION`은 `MOVE`/`SET-WINDOW`로 시야가 갱신될 때도 추가 발행될 수 있다.
4 changes: 3 additions & 1 deletion docs/Other/API_spec/WebSocket/Server/BOMB-POSITION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# BOMB-POSITION

이는 사용자가 지뢰를 설치했을 때, 해당 지점을 시야에 포함하는 사용자에게 발행됩니다.
이는 아래 경우에 해당 지점을 시야에 포함하는 사용자에게 발행됩니다.
- 사용자가 지뢰를 설치했을 때
- 사용자가 `MOVE`/`SET-WINDOW`로 시야를 갱신했을 때

## Payload
```json
Expand Down
26 changes: 19 additions & 7 deletions handler/bomb/internal/bomb.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from core.broker import EventBroker
from core.lifecycle import HLife, LifeCycle

from data.board import Point, abs_to_sec, SectionFlag
from data.board import Point, PointRange, abs_to_sec, SectionFlag
from data.board.emitter import TileEmitter
from data.bomb import InstalledBomb, BombEmitter
from data.cursor import Cursor
from handler.board.storage import _get_db, get_section
from handler.board.internal.section_handling.upgrade_section import upgrade_interaction_section
from loguru import logger
Expand All @@ -14,31 +15,42 @@

class BombHandler:
@classmethod
async def install_bomb(cls, cur_id: str, point: Point):
async def install_bomb(cls, cursor: Cursor, point: Point):
from .bomb_scheduler import enqueue_installed_bomb

delay_seconds = BombConfig.DEFAULT_DELAY_SECONDS
loop = asyncio.get_running_loop()
active_at = datetime.now() + timedelta(seconds=delay_seconds)
active_at_mono = loop.time() + delay_seconds
installed_bomb = InstalledBomb(
cur_id=cur_id,
color=cursor.color,
position=point,
explosion_range=BombConfig.EXPLOSION_RANGE,
active_at=active_at,
active_at_mono=active_at_mono,
)
await enqueue_installed_bomb(installed_bomb)
await enqueue_installed_bomb(owner_id=cursor.id, bomb=installed_bomb)

installed_events = BombEmitter.get_installed_events(installed_bomb)
installed_events = BombEmitter.get_installed_events(cursor.id, installed_bomb)
for event in installed_events:
await EventBroker.publish(event=event)

@classmethod
async def get_installed_bombs_in_range(cls, point_range: PointRange) -> list[InstalledBomb]:
from .bomb_scheduler import get_pending_bombs

bombs = await get_pending_bombs()
return [
bomb
for bomb in bombs
if point_range.is_in(bomb.position)
]

@classmethod
@LifeCycle.with_async_lifecycle(
factory=HLife.create_factory("BombHandler", "explode_bomb")
)
async def explode_bomb(cls, installed_bomb: InstalledBomb):
async def explode_bomb(cls, owner_id: str, installed_bomb: InstalledBomb):
hlife = HLife.get_lifecycle()
point = installed_bomb.position

Expand All @@ -53,7 +65,7 @@ async def explode_bomb(cls, installed_bomb: InstalledBomb):

old_tile = section.at_map_tile_by_abs_point(point)

draw_events = BombEmitter.get_draw_events(installed_bomb)
draw_events = BombEmitter.get_draw_events(owner_id, installed_bomb)
for event in draw_events:
await EventBroker.publish(event=event)

Expand Down
33 changes: 24 additions & 9 deletions handler/bomb/internal/bomb_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class BombScheduler:
def __init__(self) -> None:
self._queue: list[tuple[float, int, InstalledBomb]] = []
self._queue: list[tuple[float, int, str, InstalledBomb]] = []
self._seq: int = 0
self._condition = asyncio.Condition()
self._task: asyncio.Task | None = None
Expand All @@ -37,12 +37,20 @@ async def stop(self) -> None:
logger.exception("BombScheduler 종료 중 오류")
self._task = None

async def enqueue(self, bomb: InstalledBomb) -> None:
async def enqueue(self, owner_id: str, bomb: InstalledBomb) -> None:
async with self._condition:
self._seq += 1
heapq.heappush(self._queue, (bomb.active_at_mono, self._seq, bomb))
heapq.heappush(self._queue, (bomb.active_at_mono, self._seq, owner_id, bomb))
self._condition.notify()

async def get_pending_bombs(self) -> list[InstalledBomb]:
async with self._condition:
# 활성 시각 기준으로 정렬된 snapshot을 반환한다.
return [
bomb.copy()
for _, _, _, bomb in sorted(self._queue)
]

async def _run(self) -> None:
loop = asyncio.get_running_loop()
while True:
Expand All @@ -53,7 +61,7 @@ async def _run(self) -> None:
return

while True:
active_at_mono, _, bomb = self._queue[0]
active_at_mono, _, owner_id, bomb = self._queue[0]
now = loop.time()
delay = active_at_mono - now
if delay <= 0:
Expand All @@ -68,11 +76,12 @@ async def _run(self) -> None:
return

try:
await BombHandler.explode_bomb(bomb)
await BombHandler.explode_bomb(owner_id, bomb)
except Exception:
logger.exception(
"폭탄 폭발 처리 실패: cur_id=%s position=%s",
bomb.cur_id,
"폭탄 폭발 처리 실패: owner_id=%s color=%s position=%s",
owner_id,
int(bomb.color),
bomb.position,
)

Expand Down Expand Up @@ -114,7 +123,13 @@ async def stop_bomb_scheduler() -> None:
_SCHEDULERS.pop(key, None)


async def enqueue_installed_bomb(bomb: InstalledBomb) -> None:
async def enqueue_installed_bomb(owner_id: str, bomb: InstalledBomb) -> None:
scheduler = _get_scheduler()
await scheduler.start()
await scheduler.enqueue(owner_id, bomb)


async def get_pending_bombs() -> list[InstalledBomb]:
scheduler = _get_scheduler()
await scheduler.start()
await scheduler.enqueue(bomb)
return await scheduler.get_pending_bombs()
2 changes: 1 addition & 1 deletion receiver/external/install_bomb.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ async def install_bomb_receiver(event: INSTALL_BOMB_EVENT):

await CursorHandler.grant_item(cursor, ItemType.BOMB, -1)

await BombHandler.install_bomb(cursor.id, point)
await BombHandler.install_bomb(cursor, point)
4 changes: 1 addition & 3 deletions receiver/internal/installed_bomb.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@
@EventBroker.add_receiver(InternalEvent.INSTALLED_BOMB)
@LifeCycle.with_async_lifecycle(factory=RLife.create_factory)
async def installed_bomb_receiver(event: INSTALLED_BOMB_EVENT):
cursor_id = event.payload.id
bomb = event.payload.data
point = bomb.position
cursor = await CursorHandler.get_by_id(cursor_id)

watching_cursors = await CursorHandler.get_cursor_by_watching_range(
PointRange(point, point)
Expand All @@ -28,7 +26,7 @@ async def installed_bomb_receiver(event: INSTALLED_BOMB_EVENT):
bomb_installed_event = Event(
event_name=ServerEvent.BOMB_POSITION,
payload=ServerMessage.BombPosition(
color=int(cursor.color),
color=int(bomb.color),
position=point
)
)
Expand Down
12 changes: 12 additions & 0 deletions receiver/internal/set_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from handler.connection import ConnectionHandler
from handler.board import BoardHandler
from handler.cursor_board import CursorBoardHandler
from handler.bomb import BombHandler

SET_WINDOW_EVENT = Event[IdDataPayload[str, Cursor] | IdPayload[str]]

Expand Down Expand Up @@ -57,6 +58,17 @@ async def set_window_receiver(event: SET_WINDOW_EVENT):

await ConnectionHandler.multicast([id], colored_tiles_event)

installed_bombs = await BombHandler.get_installed_bombs_in_range(window_range)
for bomb in installed_bombs:
bomb_event = Event(
event_name=ServerEvent.BOMB_POSITION,
payload=ServerMessage.BombPosition(
color=int(bomb.color),
position=bomb.position,
),
)
await ConnectionHandler.multicast([id], bomb_event)

# 커서 정보 조회 및 전송
cursors = await CursorHandler.get_cursors_by_cursor_window(cursor)

Expand Down
111 changes: 111 additions & 0 deletions tests/integration/test_ft008.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,117 @@ async def test_ft008_scenario_install_bomb():
)


@patch.object(BombConfig, "DEFAULT_DELAY_SECONDS", new=1.0)
@patch.object(BombConfig, "EXPLOSION_RANGE", new=1)
@patch.object(BoardConfig, "LENGTH", new=4)
@patch("server.initialize_board", new=empty_board_map)
@pytest.mark.asyncio
async def test_ft008_scenario_bomb_position_when_move_into_view():
"""
시나리오
1. 시야 밖의 설치 지뢰는 BOMB-POSITION을 받지 않는다.
2. move로 시야에 들어오면 BOMB-POSITION을 받는다.
3. 시야 내에서 move를 반복하면 BOMB-POSITION이 재전송된다.
"""
with PytestTCM(app).append_client(CL_A).append_client(CL_B) as tcm:
cl_a = tcm.get_client(CL_A)
cl_b = tcm.get_client(CL_B)

positions = {
CL_A: Point(0, 0),
CL_B: Point(3, 3),
}
items_a = Items()
items_a.grant_item(ItemType.BOMB, 1)
fixed_active_at = datetime(2025, 1, 1, 0, 0, 0)

with patch(
"data.cursor.Cursor.create",
side_effect=create_cursor_by_id(
positions,
items_map={CL_A: items_a},
active_at_map={CL_A: fixed_active_at, CL_B: fixed_active_at}
)
):
cl_a.ws.send_json({
"header": {"event": ClientEvent.CREATE_CURSOR.value},
"payload": {"width": 1, "height": 1, "color": Color.RED.value}
})
cl_b.ws.send_json({
"header": {"event": ClientEvent.CREATE_CURSOR.value},
"payload": {"width": 1, "height": 1, "color": Color.BLUE.value}
})

assert_wait_event(cl_a.conn.send, ServerEvent.CURSORS_STATE)
assert_wait_event(cl_b.conn.send, ServerEvent.CURSORS_STATE)

cl_a.conn.send.await_args_list.clear()
cl_b.conn.send.await_args_list.clear()

bomb_point = Point(1, 1)

cl_a.ws.send_json({
"header": {"event": ClientEvent.INSTALL_BOMB.value},
"payload": {"position": {"x": bomb_point.x, "y": bomb_point.y}}
})

# 설치자는 즉시 설치 위치 알림을 받는다.
assert_wait_call_if(
cl_a.conn.send,
lambda msg: (
msg.event.event_name == ServerEvent.BOMB_POSITION and
msg.event.payload.color == int(Color.RED) and
msg.event.payload.position == bomb_point
),
timeout=1.5,
error_msg="설치자가 BOMB_POSITION를 받지 못함"
)

# 시야 밖인 B는 설치 시점 알림을 받지 않는다.
assert_no_event(
cl_b.conn.send,
ServerEvent.BOMB_POSITION,
timeout=0.4,
reason="시야 밖 cursor는 설치 시점 BOMB_POSITION를 받으면 안됨"
)

# B를 폭탄 위치 시야로 이동
cl_b.ws.send_json({
"header": {"event": ClientEvent.MOVE.value},
"payload": {"position": {"x": 2, "y": 2}}
})
assert_wait_event(cl_b.conn.send, ServerEvent.CURSORS_STATE)

# 시야에 들어온 시점에 BOMB_POSITION 수신
assert_wait_call_if(
cl_b.conn.send,
lambda msg: (
msg.event.event_name == ServerEvent.BOMB_POSITION and
msg.event.payload.color == int(Color.RED) and
msg.event.payload.position == bomb_point
),
timeout=1.5,
error_msg="move로 시야 진입 후 B가 BOMB_POSITION를 받지 못함"
)

# 시야 안에서 move를 반복해도 BOMB_POSITION 재전송
cl_b.ws.send_json({
"header": {"event": ClientEvent.MOVE.value},
"payload": {"position": {"x": 2, "y": 1}}
})
assert_wait_event(cl_b.conn.send, ServerEvent.CURSORS_STATE)
assert_wait_call_if(
cl_b.conn.send,
lambda msg: (
msg.event.event_name == ServerEvent.BOMB_POSITION and
msg.event.payload.color == int(Color.RED) and
msg.event.payload.position == bomb_point
),
timeout=1.5,
error_msg="시야 내 move 이후 BOMB_POSITION 재전송을 받지 못함"
)


# -------------------------
# FT-008: 비즈니스 규칙
# -------------------------
Expand Down
Loading