Skip to content

Commit 35f3382

Browse files
committed
[PR-9] hardening multiplayer logics
1 parent ec5c76d commit 35f3382

48 files changed

Lines changed: 5599 additions & 304 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,10 @@ Thumbs.db
4545
ingame_base.png
4646

4747
# Logs
48-
*.log
48+
*.log
49+
50+
# DB
51+
*.db
52+
53+
# env
54+
.env

backend/app/api/me.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@
1111
from app.database import get_db
1212
from app.dependencies import get_current_user, require_registered
1313
from app.models.db import User
14-
from app.repositories import collection_repo
14+
from app.repositories import collection_repo, profile_repo
1515
from app.schemas.profile import CollectionEntry, CollectionResponse, ProfileResponse
16+
from app.schemas.progress import (
17+
CollectionUnlockRequest,
18+
CollectionUnlockResponse,
19+
CurrencyUpdateRequest,
20+
CurrencyUpdateResponse,
21+
ProgressMergeRequest,
22+
ProgressMergeResponse,
23+
)
1624

1725
router = APIRouter(prefix="/me", tags=["me"])
1826

@@ -58,3 +66,82 @@ def get_collection(
5866
for row in rows
5967
]
6068
return CollectionResponse(items=items, total=len(items))
69+
70+
71+
@router.post("/progress/merge", response_model=ProgressMergeResponse)
72+
def merge_progress(
73+
req: ProgressMergeRequest,
74+
user: User = Depends(require_registered),
75+
db: Session = Depends(get_db),
76+
) -> ProgressMergeResponse:
77+
"""
78+
Merge local single-player progress into the registered server account.
79+
80+
Merge strategy is intentionally conservative for beta:
81+
- currency keeps the larger of {server, local}
82+
- collection becomes the union of {server, local}
83+
84+
This protects the common "old local save + new online account" case
85+
without forcing the client to reconcile multiple sources first.
86+
"""
87+
current_rows = collection_repo.get_for_user(db, user.id)
88+
current_ids = {row.soldier_id for row in current_rows}
89+
merged_ids = current_ids | set(req.collected_soldier_ids)
90+
91+
for soldier_id in sorted(merged_ids - current_ids):
92+
collection_repo.add_soldier(
93+
db,
94+
user.id,
95+
soldier_id,
96+
source="client_merge",
97+
)
98+
99+
merged_currency = max(user.profile.currency, req.currency)
100+
if user.profile.currency != merged_currency:
101+
profile_repo.set_currency(db, user.profile, merged_currency)
102+
103+
return ProgressMergeResponse(
104+
currency=merged_currency,
105+
collected_soldier_ids=sorted(merged_ids),
106+
total_collected=len(merged_ids),
107+
)
108+
109+
110+
@router.put("/currency", response_model=CurrencyUpdateResponse)
111+
def update_currency(
112+
req: CurrencyUpdateRequest,
113+
user: User = Depends(require_registered),
114+
db: Session = Depends(get_db),
115+
) -> CurrencyUpdateResponse:
116+
"""
117+
Persist the exact wallet amount for the authenticated registered user.
118+
"""
119+
profile_repo.set_currency(db, user.profile, req.currency)
120+
return CurrencyUpdateResponse(currency=req.currency)
121+
122+
123+
@router.post("/collection/unlock", response_model=CollectionUnlockResponse)
124+
def unlock_collection_entry(
125+
req: CollectionUnlockRequest,
126+
user: User = Depends(require_registered),
127+
db: Session = Depends(get_db),
128+
) -> CollectionUnlockResponse:
129+
"""
130+
Idempotently unlock a collected soldier for the authenticated user.
131+
"""
132+
added = False
133+
if not collection_repo.has_soldier(db, user.id, req.soldier_id):
134+
collection_repo.add_soldier(
135+
db,
136+
user.id,
137+
req.soldier_id,
138+
source="client_unlock",
139+
)
140+
added = True
141+
142+
total = len(collection_repo.get_for_user(db, user.id))
143+
return CollectionUnlockResponse(
144+
soldier_id=req.soldier_id,
145+
added=added,
146+
total_collected=total,
147+
)

backend/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Settings(BaseSettings):
4444
# select a card before auto-playing with bot logic.
4545
RECONNECT_GRACE_SECONDS: int = 45
4646
CARD_SELECTION_TIMEOUT_SECONDS: int = 30
47+
ROUND_SETTLEMENT_TIMEOUT_SECONDS: int = 8
4748

4849
# Quick-match matchmaking (PR-06).
4950
# QUICK_MATCH_FILL_SECONDS: how long to wait for a full 4-player lobby

backend/app/logging_config.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,12 @@ class _JsonFormatter(logging.Formatter):
7070

7171
def format(self, record: logging.LogRecord) -> str:
7272
record.message = record.getMessage()
73-
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime(
74-
"%Y-%m-%dT%H:%M:%S.%f"
75-
)[:-3] + "Z"
73+
ts = (
74+
datetime.fromtimestamp(record.created, tz=timezone.utc).strftime(
75+
"%Y-%m-%dT%H:%M:%S.%f"
76+
)[:-3]
77+
+ "Z"
78+
)
7679

7780
payload: dict[str, Any] = {
7881
"ts": ts,

backend/app/models/match.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class TurnStep:
6060
penalty_score: int
6161
had_to_take_row: bool
6262
order: int # 1-based placement order
63+
penalty_card_count: int = 0 # number of penalty cards taken in this placement
6364

6465

6566
@dataclass
@@ -99,3 +100,13 @@ class ActiveMatch:
99100
# Determines whether MMR updates are applied after the game ends.
100101
# Custom-room matches always have is_ranked=False.
101102
is_ranked: bool = False
103+
104+
# Round-settlement coordination (client result screen before continuing).
105+
round_settlement_pending: bool = False
106+
round_settlement_ready_seats: set[int] = field(default_factory=set)
107+
round_summary_pending: Optional[RoundSummary] = None
108+
109+
# Seats that voluntarily left mid-match (MATCH_LEAVE).
110+
# They are converted to bot immediately but marked as eliminated
111+
# at the end of the current round in finalize_round().
112+
voluntarily_left_seats: set[int] = field(default_factory=set)

backend/app/models/room.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class RoomParticipant:
3131
display_name: str
3232
controller_type: SeatControllerType
3333
is_ready: bool = False
34+
account_type: str = "guest" # "registered" | "guest" | "bot"
3435
user_id: Optional[str] = None # None for guests and bots
3536
connection_id: Optional[str] = None # None for bots
3637

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Database operations for Profile rows.
3+
"""
4+
5+
from datetime import datetime, timezone
6+
7+
from sqlalchemy.orm import Session
8+
9+
from app.models.db import Profile
10+
11+
12+
def _utcnow() -> datetime:
13+
return datetime.now(timezone.utc).replace(tzinfo=None)
14+
15+
16+
def set_currency(db: Session, profile: Profile, currency: int) -> Profile:
17+
"""Persist the exact wallet amount for the profile."""
18+
profile.currency = currency
19+
profile.updated_at = _utcnow()
20+
db.commit()
21+
db.refresh(profile)
22+
return profile

backend/app/schemas/progress.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Schemas for client progress synchronisation.
3+
4+
These endpoints are intentionally narrow:
5+
- initial merge of local single-player progress into a registered account
6+
- exact currency updates after in-game earn/spend events
7+
- idempotent soldier unlock writes
8+
"""
9+
10+
from pydantic import BaseModel, Field
11+
12+
13+
class ProgressMergeRequest(BaseModel):
14+
currency: int = Field(ge=0)
15+
collected_soldier_ids: list[int] = Field(default_factory=list)
16+
17+
18+
class ProgressMergeResponse(BaseModel):
19+
currency: int
20+
collected_soldier_ids: list[int]
21+
total_collected: int
22+
23+
24+
class CurrencyUpdateRequest(BaseModel):
25+
currency: int = Field(ge=0)
26+
27+
28+
class CurrencyUpdateResponse(BaseModel):
29+
currency: int
30+
31+
32+
class CollectionUnlockRequest(BaseModel):
33+
soldier_id: int = Field(ge=1)
34+
35+
36+
class CollectionUnlockResponse(BaseModel):
37+
soldier_id: int
38+
added: bool
39+
total_collected: int

backend/app/services/match_service.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,7 @@ def create_match(self, room: Room) -> ActiveMatch:
7979

8080
ai_ctrl: Optional[AIPlayer] = AIPlayer(player) if is_bot else None
8181

82-
# Derive account_type from the seat's identity:
83-
# bots are "bot"; registered users have a user_id; guests do not.
84-
if is_bot:
85-
account_type = "bot"
86-
elif participant.user_id is not None:
87-
account_type = "registered"
88-
else:
89-
account_type = "guest"
82+
account_type = participant.account_type if not is_bot else "bot"
9083

9184
seats[seat_idx] = MatchSeat(
9285
seat_index=seat_idx,
@@ -146,6 +139,47 @@ def start_next_round(self, match: ActiveMatch) -> None:
146139
"""Start the next round (used after ROUND_RESULT is broadcast)."""
147140
self._start_round(match)
148141

142+
def begin_round_settlement(self, match: ActiveMatch, summary: RoundSummary) -> None:
143+
"""Mark the match as waiting on the round-settlement screen."""
144+
match.round_settlement_pending = True
145+
match.round_settlement_ready_seats.clear()
146+
match.round_summary_pending = summary
147+
148+
def has_round_settlement_pending(self, match: ActiveMatch) -> bool:
149+
return match.round_settlement_pending and match.round_summary_pending is not None
150+
151+
def mark_round_ready(self, match: ActiveMatch, seat_index: int) -> bool:
152+
"""
153+
Record one human seat's acknowledgement of the round-settlement screen.
154+
155+
Returns True once every human seat still controlled by a REMOTE player
156+
has acknowledged.
157+
"""
158+
if not self.has_round_settlement_pending(match):
159+
raise MatchError("Round settlement is not active")
160+
161+
seat = match.seats.get(seat_index)
162+
if seat is None:
163+
raise MatchError(f"Seat {seat_index} not found in match")
164+
if seat.controller_type != SeatControllerType.REMOTE:
165+
raise MatchError("Only human (REMOTE) seats can acknowledge settlement")
166+
167+
match.round_settlement_ready_seats.add(seat_index)
168+
required = {
169+
s.seat_index
170+
for s in match.seats.values()
171+
if s.controller_type == SeatControllerType.REMOTE and not s.player.is_eliminated # type: ignore[union-attr]
172+
}
173+
return required.issubset(match.round_settlement_ready_seats)
174+
175+
def clear_round_settlement(self, match: ActiveMatch) -> Optional[RoundSummary]:
176+
"""Clear the active round-settlement state and return its summary."""
177+
summary = match.round_summary_pending
178+
match.round_settlement_pending = False
179+
match.round_settlement_ready_seats.clear()
180+
match.round_summary_pending = None
181+
return summary
182+
149183
def reselect_bots(self, match: ActiveMatch) -> None:
150184
"""
151185
Auto-select cards for all non-eliminated bot seats.
@@ -287,6 +321,7 @@ def resolve_turn_stepwise(
287321
penalty_score=placement.penalty_score,
288322
had_to_take_row=placement.had_to_take_row,
289323
order=order_idx + 1,
324+
penalty_card_count=len(placement.penalty_cards),
290325
)
291326
accumulated.append(step)
292327
# Temporarily set last_turn_steps so build_public_state reflects
@@ -307,12 +342,36 @@ def finalize_round(self, match: ActiveMatch) -> RoundSummary:
307342
Commit round scores and determine if the game is over.
308343
309344
Must be called after resolve_turn() confirms is_round_over().
345+
Players who voluntarily left mid-match are force-eliminated here.
310346
"""
311347
rules: GameRules = match.rules # type: ignore[assignment]
312348

349+
# Force-eliminate players who left voluntarily during this round.
350+
for seat_idx in match.voluntarily_left_seats:
351+
seat = match.seats.get(seat_idx)
352+
if seat is not None and not seat.player.is_eliminated: # type: ignore[union-attr]
353+
seat.player.is_eliminated = True # type: ignore[union-attr]
354+
match.voluntarily_left_seats.clear()
355+
356+
# Sole-survivor check: if only one active player remains after
357+
# voluntary eliminations, declare them the winner immediately
358+
# — their score doesn't matter (they outlasted everyone else).
359+
active_before_commit = [p for p in rules.players if not p.is_eliminated]
360+
sole_survivor_win = len(active_before_commit) == 1
361+
313362
# {player_id: (round_danger, new_total)}
314363
score_results = rules.commit_round_scores()
315364

365+
# Override game-end result for the sole-survivor case: the last
366+
# remaining player wins even if commit_round_scores() would have
367+
# eliminated them for exceeding 66 danger.
368+
if sole_survivor_win:
369+
survivor = active_before_commit[0]
370+
rules.game_over = True
371+
rules.winner = survivor
372+
# Undo the elimination that commit_round_scores may have set.
373+
survivor.is_eliminated = False
374+
316375
round_danger: dict[int, int] = {}
317376
total_scores: dict[int, int] = {}
318377
for player_id, (rd, total) in score_results.items():
@@ -438,6 +497,7 @@ def build_private_state(self, match: ActiveMatch, seat_index: int) -> PrivatePla
438497
seat_index=seat_index,
439498
hand=hand,
440499
has_selected=seat.player.selected_card is not None, # type: ignore[union-attr]
500+
is_eliminated=seat.player.is_eliminated, # type: ignore[union-attr]
441501
)
442502

443503
# ------------------------------------------------------------------

backend/app/services/matchmaking_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ def build_quick_match_room(
202202
display_name=entry.display_name,
203203
controller_type=SeatControllerType.REMOTE,
204204
is_ready=True,
205+
account_type=entry.account_type,
205206
user_id=entry.user_id,
206207
connection_id=entry.connection_id,
207208
)
@@ -213,6 +214,7 @@ def build_quick_match_room(
213214
display_name=f"AI Bot {seat_idx + 1}",
214215
controller_type=SeatControllerType.BOT,
215216
is_ready=True,
217+
account_type="bot",
216218
user_id=None,
217219
connection_id=None,
218220
)

0 commit comments

Comments
 (0)