EZTIME2025 commited on
Commit
d9c12b7
·
1 Parent(s): 511ed36

trade - add multiple trade option

Browse files
game_viz.log CHANGED
@@ -895,3 +895,34 @@ Current Player: ► c
895
  -----
896
  Board Tiles: 19 tiles configured
897
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  -----
896
  Board Tiles: 19 tiles configured
897
 
898
+ ✓ c proposed a trade
899
+
900
+ ==================================================
901
+  GAME STATE 
902
+ ==================================================
903
+
904
+ Turn: 11
905
+ Current Player: ► c
906
+
907
+ PLAYERS
908
+ -------
909
+
910
+ a
911
+ Victory Points: 2
912
+ Resources: None
913
+ Buildings: Settlements: 2, Cities: 0, Roads: 2
914
+
915
+ b
916
+ Victory Points: 2
917
+ Resources: None
918
+ Buildings: Settlements: 2, Cities: 0, Roads: 4
919
+
920
+ ► c
921
+ Victory Points: 2
922
+ Resources: None
923
+ Buildings: Settlements: 2, Cities: 0, Roads: 2
924
+
925
+ BOARD
926
+ -----
927
+ Board Tiles: 19 tiles configured
928
+
pycatan/game_moves_3Players.txt CHANGED
@@ -29,3 +29,5 @@ buy dev card
29
  use road rd 14 24 rd 24 25
30
  end
31
  roll
 
 
 
29
  use road rd 14 24 rd 24 25
30
  end
31
  roll
32
+ t player a nothing for ore 2
33
+ y
pycatan/human_user.py CHANGED
@@ -403,12 +403,25 @@ class HumanUser(User):
403
  raise UserInputError("Invalid trade format or resource names")
404
 
405
  def _parse_player_trade(self, parts: List[str], game_state: GameState = None) -> Action:
406
- """Parse player trading command."""
407
- if len(parts) < 3:
408
- raise UserInputError("Player trade format: 'trade player [player_id_or_name] [your_resource] [their_resource]'")
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
  try:
411
- # Try to parse as player ID first
412
  try:
413
  target_player = int(parts[0])
414
  except ValueError:
@@ -425,19 +438,151 @@ class HumanUser(User):
425
  if target_player is None:
426
  raise UserInputError(f"Player '{parts[0]}' not found. Use player name or ID (0-{len(game_state.players_state)-1 if game_state else 3})")
427
 
428
- give_resource = self._parse_resource(parts[1])
429
- get_resource = self._parse_resource(parts[2])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  params = {
432
  'target_player': target_player,
433
- 'offer': {give_resource.name.lower(): 1},
434
- 'request': {get_resource.name.lower(): 1}
435
  }
436
 
437
  return Action(ActionType.TRADE_PROPOSE, self.user_id, params)
438
 
439
- except (ValueError, KeyError):
440
- raise UserInputError("Invalid trade format or resource names")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
  def _parse_use_dev_card(self, parts: List[str], game_state: GameState) -> Action:
443
  """Parse development card usage command."""
@@ -693,8 +838,15 @@ class HumanUser(User):
693
  print()
694
  print("💰 TRADING:")
695
  print(" trade bank <give> <amount> <get> <amount>")
696
- print(" trade player <id_or_name> <give> <get> (short: t)")
697
- print(" Examples: 'trade bank wood 4 sheep 1' or 't player v wood sheep'")
 
 
 
 
 
 
 
698
  print()
699
  print("🃏 DEVELOPMENT CARDS:")
700
  print(" buy / dev - Buy dev card (cost: 1 Ore + 1 Sheep + 1 Wheat)")
 
403
  raise UserInputError("Invalid trade format or resource names")
404
 
405
  def _parse_player_trade(self, parts: List[str], game_state: GameState = None) -> Action:
406
+ """
407
+ Parse player trading command.
408
+
409
+ Supports flexible trading formats:
410
+ - Simple 1:1: 'trade player 1 wood sheep'
411
+ - With amounts: 'trade player 1 wood 2 sheep 1'
412
+ - Multiple resources: 'trade player 1 wood 2 brick 1 for wheat 2 ore 1'
413
+ - Gifts: 'trade player 1 nothing wheat 1' or 'trade player 1 wood 2 nothing'
414
+ """
415
+ if len(parts) < 2:
416
+ raise UserInputError("Player trade format: 'trade player [player_id_or_name] [give_resources] [for/get] [receive_resources]'\n"
417
+ "Examples:\n"
418
+ " - trade player 1 wood sheep (1:1 simple)\n"
419
+ " - trade player 1 wood 2 sheep 1 (with amounts)\n"
420
+ " - trade player 1 wood 2 brick 1 for wheat 2 (separator word)\n"
421
+ " - trade player 1 nothing wheat 1 (gift from them)")
422
 
423
  try:
424
+ # Parse target player
425
  try:
426
  target_player = int(parts[0])
427
  except ValueError:
 
438
  if target_player is None:
439
  raise UserInputError(f"Player '{parts[0]}' not found. Use player name or ID (0-{len(game_state.players_state)-1 if game_state else 3})")
440
 
441
+ # Find separator word (for/get/want) or split at middle
442
+ separator_words = ['for', 'get', 'want', 'receive']
443
+ separator_index = None
444
+
445
+ for i, part in enumerate(parts[1:], start=1):
446
+ if part.lower() in separator_words:
447
+ separator_index = i
448
+ break
449
+
450
+ # Parse offer and request based on separator
451
+ if separator_index:
452
+ # Has separator word
453
+ offer_parts = parts[1:separator_index]
454
+ request_parts = parts[separator_index + 1:]
455
+ else:
456
+ # No separator - try to detect format
457
+ # Check if it's old simple format (2 resources) or new format with amounts
458
+ remaining = parts[1:]
459
+
460
+ # Try to parse as pairs: [resource amount resource amount...]
461
+ offer, request = self._split_trade_resources(remaining)
462
+ offer_parts = offer
463
+ request_parts = request
464
+
465
+ # Parse offer and request
466
+ offer_dict = self._parse_resource_list(offer_parts if separator_index else offer_parts)
467
+ request_dict = self._parse_resource_list(request_parts if separator_index else request_parts)
468
 
469
  params = {
470
  'target_player': target_player,
471
+ 'offer': offer_dict,
472
+ 'request': request_dict
473
  }
474
 
475
  return Action(ActionType.TRADE_PROPOSE, self.user_id, params)
476
 
477
+ except (ValueError, KeyError) as e:
478
+ raise UserInputError(f"Invalid trade format: {e}")
479
+
480
+ def _split_trade_resources(self, parts: List[str]) -> tuple:
481
+ """
482
+ Split trade resources into offer and request when no separator word is present.
483
+ Handles both simple format (wood sheep) and amount format (wood 2 sheep 1).
484
+
485
+ Strategy: Parse from left, consuming resource-amount pairs.
486
+ When we've consumed about half the tokens, split there.
487
+ """
488
+ if len(parts) == 2:
489
+ # Simple 1:1 format: [resource1] [resource2]
490
+ return [parts[0]], [parts[1]]
491
+
492
+ # Parse tokens and identify resource groups
493
+ # A group is: resource_name [optional_number]
494
+ groups = []
495
+ i = 0
496
+ while i < len(parts):
497
+ token = parts[i]
498
+
499
+ # Skip standalone numbers (shouldn't happen but safeguard)
500
+ if token.isdigit():
501
+ i += 1
502
+ continue
503
+
504
+ # Check if this is 'nothing' keyword
505
+ if token.lower() == 'nothing':
506
+ groups.append([token])
507
+ i += 1
508
+ continue
509
+
510
+ # Check if next token is a number
511
+ if i + 1 < len(parts) and parts[i + 1].isdigit():
512
+ # Resource with amount: [resource, amount]
513
+ groups.append([token, parts[i + 1]])
514
+ i += 2
515
+ else:
516
+ # Resource without amount: [resource]
517
+ groups.append([token])
518
+ i += 1
519
+
520
+ # Split groups roughly in half
521
+ mid = len(groups) // 2
522
+
523
+ # Flatten groups back to parts
524
+ offer_parts = []
525
+ for group in groups[:mid]:
526
+ offer_parts.extend(group)
527
+
528
+ request_parts = []
529
+ for group in groups[mid:]:
530
+ request_parts.extend(group)
531
+
532
+ return offer_parts, request_parts
533
+
534
+ def _parse_resource_list(self, parts: List[str]) -> dict:
535
+ """
536
+ Parse a list of resources with optional amounts.
537
+ Examples:
538
+ - ['wood', 'brick'] -> {'wood': 1, 'brick': 1}
539
+ - ['wood', '2', 'brick', '1'] -> {'wood': 2, 'brick': 1}
540
+ - ['nothing'] -> {}
541
+ - [] -> {}
542
+ """
543
+ if not parts or (len(parts) == 1 and parts[0].lower() == 'nothing'):
544
+ return {}
545
+
546
+ result = {}
547
+ i = 0
548
+
549
+ while i < len(parts):
550
+ # Skip if this is a number (should have been consumed as amount)
551
+ if parts[i].isdigit():
552
+ i += 1
553
+ continue
554
+
555
+ resource_name = parts[i]
556
+
557
+ # Check for 'nothing' keyword
558
+ if resource_name.lower() == 'nothing':
559
+ i += 1
560
+ continue
561
+
562
+ # Check if next part is a number (amount)
563
+ amount = 1
564
+ if i + 1 < len(parts) and parts[i + 1].isdigit():
565
+ amount = int(parts[i + 1])
566
+ i += 2
567
+ else:
568
+ i += 1
569
+
570
+ # Parse the resource
571
+ try:
572
+ resource = self._parse_resource(resource_name)
573
+ resource_key = resource.name.lower()
574
+
575
+ # Add to result (accumulate if resource appears multiple times)
576
+ if resource_key in result:
577
+ result[resource_key] += amount
578
+ else:
579
+ result[resource_key] = amount
580
+ except UserInputError:
581
+ # If we can't parse it as a resource, skip it
582
+ # This handles edge cases
583
+ continue
584
+
585
+ return result
586
 
587
  def _parse_use_dev_card(self, parts: List[str], game_state: GameState) -> Action:
588
  """Parse development card usage command."""
 
838
  print()
839
  print("💰 TRADING:")
840
  print(" trade bank <give> <amount> <get> <amount>")
841
+ print(" Example: 'trade bank wood 4 sheep 1'")
842
+ print()
843
+ print(" trade player <id_or_name> <resources_offer> [for] <resources_want>")
844
+ print(" Simple 1:1: 't player 1 wood sheep'")
845
+ print(" With amounts: 't player bob wood 2 sheep 1'")
846
+ print(" Multiple: 't player 1 wood 2 brick 1 for wheat 2 ore 1'")
847
+ print(" Gifts: 't player 2 nothing wheat 1' (receive gift)")
848
+ print(" Gifts: 't player 2 wood 3 nothing' (give gift)")
849
+ print(" 💡 Use 'for' or 'get' to separate offer from request")
850
  print()
851
  print("🃏 DEVELOPMENT CARDS:")
852
  print(" buy / dev - Buy dev card (cost: 1 Ore + 1 Sheep + 1 Wheat)")
tests/test_human_user.py CHANGED
@@ -459,4 +459,152 @@ class TestCommandHistory:
459
  # Check that all commands were added to history
460
  assert len(self.user.command_history) == len(commands)
461
  for cmd in commands:
462
- assert cmd in self.user.command_history
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  # Check that all commands were added to history
460
  assert len(self.user.command_history) == len(commands)
461
  for cmd in commands:
462
+ assert cmd in self.user.command_history
463
+
464
+
465
+ class TestAdvancedTrading:
466
+ """Test advanced trading functionality with flexible amounts."""
467
+
468
+ def setup_method(self):
469
+ """Set up test fixtures."""
470
+ self.user = HumanUser("TestUser", 0)
471
+ self.game_state = GameState()
472
+
473
+ def test_simple_one_to_one_trade(self):
474
+ """Test simple 1:1 trade (backward compatibility)."""
475
+ action = self.user._parse_input("trade player 1 wood sheep", self.game_state)
476
+
477
+ assert action.action_type == ActionType.TRADE_PROPOSE
478
+ assert action.parameters['target_player'] == 1
479
+ assert action.parameters['offer'] == {'wood': 1}
480
+ assert action.parameters['request'] == {'sheep': 1}
481
+
482
+ def test_trade_with_amounts(self):
483
+ """Test trade with explicit amounts."""
484
+ action = self.user._parse_input("trade player 1 wood 2 sheep 1", self.game_state)
485
+
486
+ assert action.action_type == ActionType.TRADE_PROPOSE
487
+ assert action.parameters['target_player'] == 1
488
+ assert action.parameters['offer'] == {'wood': 2}
489
+ assert action.parameters['request'] == {'sheep': 1}
490
+
491
+ def test_trade_multiple_resources(self):
492
+ """Test trade with multiple resources on both sides."""
493
+ action = self.user._parse_input("trade player 1 wood 2 brick 1 for wheat 2 ore 1", self.game_state)
494
+
495
+ assert action.action_type == ActionType.TRADE_PROPOSE
496
+ assert action.parameters['target_player'] == 1
497
+ assert action.parameters['offer'] == {'wood': 2, 'brick': 1}
498
+ assert action.parameters['request'] == {'wheat': 2, 'ore': 1}
499
+
500
+ def test_trade_with_separator_words(self):
501
+ """Test trade with various separator words."""
502
+ separators = ['for', 'get', 'want', 'receive']
503
+
504
+ for sep in separators:
505
+ action = self.user._parse_input(f"trade player 1 wood 2 {sep} sheep 1", self.game_state)
506
+
507
+ assert action.action_type == ActionType.TRADE_PROPOSE
508
+ assert action.parameters['offer'] == {'wood': 2}
509
+ assert action.parameters['request'] == {'sheep': 1}
510
+
511
+ def test_gift_receiving(self):
512
+ """Test receiving a gift (nothing for something)."""
513
+ action = self.user._parse_input("trade player 1 nothing wheat 1", self.game_state)
514
+
515
+ assert action.action_type == ActionType.TRADE_PROPOSE
516
+ assert action.parameters['offer'] == {}
517
+ assert action.parameters['request'] == {'wheat': 1}
518
+
519
+ def test_gift_giving(self):
520
+ """Test giving a gift (something for nothing)."""
521
+ action = self.user._parse_input("trade player 1 wood 3 nothing", self.game_state)
522
+
523
+ assert action.action_type == ActionType.TRADE_PROPOSE
524
+ assert action.parameters['offer'] == {'wood': 3}
525
+ assert action.parameters['request'] == {}
526
+
527
+ def test_extreme_trade_zero_for_five(self):
528
+ """Test extreme trade: 0 cards for 5 cards (use separator for clarity)."""
529
+ action = self.user._parse_input("trade player 1 nothing for wheat 2 ore 2 brick 1", self.game_state)
530
+
531
+ assert action.action_type == ActionType.TRADE_PROPOSE
532
+ assert action.parameters['offer'] == {}
533
+ assert action.parameters['request'] == {'wheat': 2, 'ore': 2, 'brick': 1}
534
+
535
+ def test_extreme_trade_five_for_zero(self):
536
+ """Test extreme trade: 5 cards for 0 cards (use separator for clarity)."""
537
+ action = self.user._parse_input("trade player 1 wood 2 brick 2 sheep 1 for nothing", self.game_state)
538
+
539
+ assert action.action_type == ActionType.TRADE_PROPOSE
540
+ assert action.parameters['offer'] == {'wood': 2, 'brick': 2, 'sheep': 1}
541
+ assert action.parameters['request'] == {}
542
+
543
+ def test_complex_multi_resource_trade(self):
544
+ """Test complex trade with many resources on both sides."""
545
+ action = self.user._parse_input("trade player 1 wood 3 brick 2 sheep 1 for wheat 4 ore 2", self.game_state)
546
+
547
+ assert action.action_type == ActionType.TRADE_PROPOSE
548
+ assert action.parameters['offer'] == {'wood': 3, 'brick': 2, 'sheep': 1}
549
+ assert action.parameters['request'] == {'wheat': 4, 'ore': 2}
550
+
551
+ def test_trade_with_player_name(self):
552
+ """Test trading with player name instead of ID."""
553
+ from pycatan.actions import PlayerState
554
+
555
+ # Create PlayerState objects (it's a dataclass, pass all params)
556
+ alice = PlayerState(
557
+ player_id=0,
558
+ name="Alice",
559
+ cards=[],
560
+ dev_cards=[],
561
+ settlements=[],
562
+ cities=[],
563
+ roads=[],
564
+ victory_points=0,
565
+ longest_road_length=0,
566
+ has_longest_road=False,
567
+ has_largest_army=False,
568
+ knights_played=0
569
+ )
570
+
571
+ bob = PlayerState(
572
+ player_id=1,
573
+ name="Bob",
574
+ cards=[],
575
+ dev_cards=[],
576
+ settlements=[],
577
+ cities=[],
578
+ roads=[],
579
+ victory_points=0,
580
+ longest_road_length=0,
581
+ has_longest_road=False,
582
+ has_largest_army=False,
583
+ knights_played=0
584
+ )
585
+
586
+ # Add player states to game_state
587
+ self.game_state.players_state = [alice, bob]
588
+
589
+ action = self.user._parse_input("trade player bob wood 2 for sheep 1", self.game_state)
590
+
591
+ assert action.action_type == ActionType.TRADE_PROPOSE
592
+ assert action.parameters['target_player'] == 1 # Bob's ID
593
+ assert action.parameters['offer'] == {'wood': 2}
594
+ assert action.parameters['request'] == {'sheep': 1}
595
+
596
+ def test_trade_accumulates_duplicate_resources(self):
597
+ """Test that duplicate resources in same offer accumulate."""
598
+ action = self.user._parse_input("trade player 1 wood 2 wood 1 for sheep 1", self.game_state)
599
+
600
+ assert action.action_type == ActionType.TRADE_PROPOSE
601
+ assert action.parameters['offer'] == {'wood': 3} # 2 + 1 = 3
602
+ assert action.parameters['request'] == {'sheep': 1}
603
+
604
+ def test_short_trade_command(self):
605
+ """Test short 't' command for trading."""
606
+ action = self.user._parse_input("t player 1 wood 2 sheep 1", self.game_state)
607
+
608
+ assert action.action_type == ActionType.TRADE_PROPOSE
609
+ assert action.parameters['offer'] == {'wood': 2}
610
+ assert action.parameters['request'] == {'sheep': 1}