feature updates
This commit is contained in:
@@ -36,7 +36,34 @@
|
||||
"Bash(timeout 120 godot:*)",
|
||||
"Bash(convert:*)",
|
||||
"Bash(timeout 60 godot:*)",
|
||||
"Bash(timeout 60 ~/.local/share/Godot/bin/godot:*)"
|
||||
"Bash(timeout 60 ~/.local/share/Godot/bin/godot:*)",
|
||||
"Bash(timeout 5 godot:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(timeout 10 /home/ckoch/Downloads/Godot_v4.5.1-stable_linux.x86_64:*)",
|
||||
"Bash(for f in scripts/game/ai/*.gd)",
|
||||
"Bash(do echo \"=== $f ===\")",
|
||||
"Bash(/home/ckoch/Downloads/Godot_v4.5.1-stable_linux.x86_64:*)",
|
||||
"Bash(done)",
|
||||
"Bash(npm install)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(godot4:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(node --version:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(python tools/ai_card_reviewer.py:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(for set in 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27)",
|
||||
"Bash(do)",
|
||||
"Bash(export:*)",
|
||||
"Bash(__NEW_LINE_844f0ad1f7e619fc__ for set in 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27)",
|
||||
"Bash(__NEW_LINE_53cda2a7256fe344__ for set in 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27)",
|
||||
"Bash(__NEW_LINE_c4814b096675c7a3__ for set in 16 17 18 19 20 21 22 23 24 25 26 27)",
|
||||
"Bash(godot:*)",
|
||||
"Bash(python tools/ability_validator.py:*)",
|
||||
"Bash(which:*)",
|
||||
"Bash(python tools/ability_processor.py:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1 +1,7 @@
|
||||
/.godot/imported
|
||||
|
||||
# Environment files (contain secrets)
|
||||
.env
|
||||
*.env.local
|
||||
server/.env
|
||||
Godot_v4.2-stable_linux.x86_64
|
||||
|
||||
108
PLAN.md
Normal file
108
PLAN.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Plan: Improve Low Confidence Ability Parsing
|
||||
|
||||
## Current State
|
||||
- 1,008 abilities have LOW confidence (14.9% of total)
|
||||
- All have "No effects parsed" - the parser didn't match any pattern
|
||||
|
||||
## Pattern Categories to Fix
|
||||
|
||||
### Priority 1: High Count Patterns (700+ abilities)
|
||||
|
||||
#### 1.1 "Cannot be X" (148 abilities)
|
||||
Examples:
|
||||
- "Lann cannot be blocked by a Forward with a power greater than his"
|
||||
- "Terra cannot be chosen by opponent's Summons"
|
||||
- "Zidane cannot be blocked by a Forward of cost 4 or more"
|
||||
|
||||
**Effect Type:** `BLOCK_IMMUNITY` or `SELECTION_IMMUNITY`
|
||||
**Fields needed:** condition (power comparison, cost comparison), from (opponent summons, etc.)
|
||||
|
||||
#### 1.2 "IF conditional" (186 abilities)
|
||||
Examples:
|
||||
- "If this Forward blocks or is blocked by a Forward without First Strike, this Forward deals damage first"
|
||||
- "If Shantotto is on the field, it gains Elements of Fire, Ice, Wind..."
|
||||
- "If a Forward forming a party with Knight is dealt damage, the damage becomes 0 instead"
|
||||
|
||||
**Effect Type:** `CONDITIONAL_FIELD_EFFECT`
|
||||
**Fields needed:** condition, effect (nested)
|
||||
|
||||
#### 1.3 "Cost modification" (102 abilities)
|
||||
Examples:
|
||||
- "The cost required for your opponent to cast Summons increases by 1"
|
||||
- "The cost required to play Lightning is reduced by 1 for each [Category (XIII)] Forward"
|
||||
- "This Character can attack or use abilities the turn it enters the field" (Haste-like)
|
||||
|
||||
**Effect Type:** `COST_MODIFIER`
|
||||
**Fields needed:** target_cards (filter), amount (static or dynamic), applies_to (self/opponent)
|
||||
|
||||
#### 1.4 "Your opponent X" (86 abilities)
|
||||
Examples:
|
||||
- "Your opponent may play 1 Character Card from his/her hand"
|
||||
- "Summons or abilities of your opponent must choose Cecil if possible"
|
||||
- "Kimahri gains Elements of all the Characters your opponent controls"
|
||||
|
||||
**Effect Type:** Various - `OPPONENT_ACTION`, `TAUNT`, `GAIN_ELEMENTS`
|
||||
|
||||
#### 1.5 "Cannot X" (45 abilities)
|
||||
Examples:
|
||||
- "Lann cannot block a Forward with a power greater than his"
|
||||
- "Naji cannot attack"
|
||||
- "You cannot play Hooded Man while already in control of either Character"
|
||||
|
||||
**Effect Type:** `RESTRICTION`
|
||||
**Fields needed:** restriction_type (ATTACK, BLOCK, PLAY), condition
|
||||
|
||||
### Priority 2: Medium Count Patterns (70+ abilities)
|
||||
|
||||
#### 2.1 "Replacement effect" (32 abilities)
|
||||
Examples:
|
||||
- "the next damage dealt to you becomes 0 instead"
|
||||
- "reduce the damage by 2000 instead"
|
||||
|
||||
**Effect Type:** `DAMAGE_REPLACEMENT` or `DAMAGE_PREVENTION`
|
||||
|
||||
#### 2.2 "When trigger (embedded)" (28 abilities)
|
||||
These have triggers embedded in FIELD abilities
|
||||
|
||||
#### 2.3 "Each X" (23 abilities)
|
||||
Examples:
|
||||
- "each Forward you control gains +1000 power"
|
||||
- "each time a card is put into your Break Zone"
|
||||
|
||||
### Priority 3: Low Count Patterns
|
||||
|
||||
#### 3.1 "Return X" (17 abilities) - May already be partially handled
|
||||
#### 3.2 "You may X" (15 abilities)
|
||||
#### 3.3 "Gains ability text" (7 abilities)
|
||||
#### 3.4 "All X you control" (4 abilities)
|
||||
#### 3.5 "Other" (311 abilities) - Need further categorization
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Add FIELD ability patterns for common types
|
||||
1. Add patterns for "cannot be blocked by X"
|
||||
2. Add patterns for "cannot be chosen by X"
|
||||
3. Add patterns for "cannot attack/block" (self restriction)
|
||||
4. Add patterns for cost modification (increase/reduce for specific cards)
|
||||
|
||||
### Phase 2: Add conditional wrapper support
|
||||
1. Parse "If X, then Y" structure
|
||||
2. Support nested effects inside conditionals
|
||||
3. Handle "gains X until end of turn" with ability text
|
||||
|
||||
### Phase 3: Add opponent interaction patterns
|
||||
1. "opponent must choose X if possible" (taunt)
|
||||
2. "opponent may X" (optional opponent actions)
|
||||
3. "gains elements of opponent's cards"
|
||||
|
||||
### Phase 4: Add replacement effects
|
||||
1. "damage becomes 0 instead"
|
||||
2. "reduce damage by X instead"
|
||||
|
||||
### Phase 5: Review remaining "other" category
|
||||
- Further categorize the 311 remaining
|
||||
- Add patterns for common sub-groups
|
||||
|
||||
## Expected Outcome
|
||||
- Target: Reduce LOW confidence from 1,008 to <300
|
||||
- This would bring HIGH confidence from 79.4% to ~90%
|
||||
220867
data/abilities_processed.json
Normal file
220867
data/abilities_processed.json
Normal file
File diff suppressed because it is too large
Load Diff
16873
data/ability_issues.json
Normal file
16873
data/ability_issues.json
Normal file
File diff suppressed because it is too large
Load Diff
26599
data/cards.json
26599
data/cards.json
File diff suppressed because it is too large
Load Diff
6
data/reviewed.json
Normal file
6
data/reviewed.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"reviewed": [
|
||||
"1-001H",
|
||||
"1-002R"
|
||||
]
|
||||
}
|
||||
@@ -8,23 +8,26 @@
|
||||
"description": "Cloud, Tifa, and the AVALANCHE crew",
|
||||
"image": "starter_decks/opus1_vii_fire_earth.png",
|
||||
"cards": [
|
||||
"1-001R", "1-001R", "1-001R",
|
||||
"1-003C", "1-003C", "1-003C",
|
||||
"1-005C", "1-005C", "1-005C",
|
||||
"1-017C", "1-017C", "1-017C",
|
||||
"1-019R", "1-019R", "1-019R",
|
||||
"1-021H",
|
||||
"1-182S", "1-182S", "1-182S",
|
||||
"1-093R", "1-093R", "1-093R",
|
||||
"1-094H", "1-094H", "1-094H",
|
||||
"1-100C", "1-100C", "1-100C",
|
||||
"1-104C", "1-104C", "1-104C",
|
||||
"1-107L",
|
||||
"1-108C", "1-108C", "1-108C",
|
||||
"1-110R", "1-110R", "1-110R",
|
||||
"1-111C", "1-111C", "1-111C",
|
||||
"1-184S", "1-184S", "1-184S",
|
||||
"1-185S", "1-185S"
|
||||
"1-004C", "1-004C",
|
||||
"1-010C", "1-010C",
|
||||
"1-011C", "1-011C",
|
||||
"1-014C", "1-014C",
|
||||
"1-024C", "1-024C",
|
||||
"1-025C", "1-025C",
|
||||
"1-092C", "1-092C",
|
||||
"1-099C", "1-099C",
|
||||
"1-106C", "1-106C",
|
||||
"1-120C", "1-120C",
|
||||
"1-187S", "1-187S", "1-187S",
|
||||
"1-188S", "1-188S", "1-188S",
|
||||
"1-189S", "1-189S", "1-189S",
|
||||
"1-190S", "1-190S", "1-190S",
|
||||
"1-191S", "1-191S", "1-191S",
|
||||
"1-202S", "1-202S", "1-202S",
|
||||
"1-203S", "1-203S", "1-203S",
|
||||
"1-204S", "1-204S", "1-204S",
|
||||
"1-205S", "1-205S", "1-205S",
|
||||
"1-206S", "1-206S", "1-206S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -35,22 +38,26 @@
|
||||
"description": "Tidus, Yuna, and the FFX cast",
|
||||
"image": "starter_decks/opus1_x_water_wind.png",
|
||||
"cards": [
|
||||
"1-063R", "1-063R", "1-063R",
|
||||
"1-067C", "1-067C", "1-067C",
|
||||
"1-068C", "1-068C", "1-068C",
|
||||
"1-080H", "1-080H", "1-080H",
|
||||
"1-083H", "1-083H",
|
||||
"1-176H",
|
||||
"1-177R", "1-177R", "1-177R",
|
||||
"1-160R", "1-160R", "1-160R",
|
||||
"1-155C", "1-155C", "1-155C",
|
||||
"1-163R", "1-163R", "1-163R",
|
||||
"1-170C", "1-170C", "1-170C",
|
||||
"1-171H",
|
||||
"1-172C", "1-172C", "1-172C",
|
||||
"1-159R", "1-159R", "1-159R",
|
||||
"1-144C", "1-144C", "1-144C",
|
||||
"1-175C", "1-175C", "1-175C"
|
||||
"1-068C", "1-068C",
|
||||
"1-074R", "1-074R",
|
||||
"1-077C", "1-077C",
|
||||
"1-078C", "1-078C",
|
||||
"1-087C", "1-087C",
|
||||
"1-159C", "1-159C",
|
||||
"1-165C", "1-165C",
|
||||
"1-167C", "1-167C",
|
||||
"1-168C", "1-168C",
|
||||
"1-178R", "1-178R",
|
||||
"1-197S", "1-197S", "1-197S",
|
||||
"1-198S", "1-198S", "1-198S",
|
||||
"1-199S", "1-199S", "1-199S",
|
||||
"1-200S", "1-200S", "1-200S",
|
||||
"1-201S", "1-201S", "1-201S",
|
||||
"1-212S", "1-212S", "1-212S",
|
||||
"1-213S", "1-213S", "1-213S",
|
||||
"1-214S", "1-214S", "1-214S",
|
||||
"1-215S", "1-215S", "1-215S",
|
||||
"1-216S", "1-216S", "1-216S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -61,21 +68,26 @@
|
||||
"description": "Lightning, Snow, and the FFXIII cast",
|
||||
"image": "starter_decks/opus1_xiii_ice_lightning.png",
|
||||
"cards": [
|
||||
"1-038R", "1-038R", "1-038R",
|
||||
"1-041R", "1-041R", "1-041R",
|
||||
"1-043C", "1-043C", "1-043C",
|
||||
"1-048R", "1-048R", "1-048R",
|
||||
"1-181S", "1-181S", "1-181S",
|
||||
"1-036R", "1-036R", "1-036R",
|
||||
"1-033C", "1-033C", "1-033C",
|
||||
"1-116R", "1-116R", "1-116R",
|
||||
"1-121R", "1-121R", "1-121R",
|
||||
"1-135R", "1-135R", "1-135R",
|
||||
"1-136C", "1-136C", "1-136C",
|
||||
"1-140R", "1-140R", "1-140R",
|
||||
"1-141H",
|
||||
"1-142H",
|
||||
"1-183S", "1-183S", "1-183S"
|
||||
"1-036C", "1-036C",
|
||||
"1-038R", "1-038R",
|
||||
"1-040C", "1-040C",
|
||||
"1-054C", "1-054C",
|
||||
"1-055C", "1-055C",
|
||||
"1-123R", "1-123R",
|
||||
"1-133C", "1-133C",
|
||||
"1-138C", "1-138C",
|
||||
"1-140C", "1-140C",
|
||||
"1-147C", "1-147C",
|
||||
"1-192S", "1-192S", "1-192S",
|
||||
"1-193S", "1-193S", "1-193S",
|
||||
"1-194S", "1-194S", "1-194S",
|
||||
"1-195S", "1-195S", "1-195S",
|
||||
"1-196S", "1-196S", "1-196S",
|
||||
"1-207S", "1-207S", "1-207S",
|
||||
"1-208S", "1-208S", "1-208S",
|
||||
"1-209S", "1-209S", "1-209S",
|
||||
"1-210S", "1-210S", "1-210S",
|
||||
"1-211S", "1-211S", "1-211S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -86,20 +98,28 @@
|
||||
"description": "Class Zero cadets from Type-0",
|
||||
"image": "starter_decks/opus3_type0_wind_lightning.png",
|
||||
"cards": [
|
||||
"3-054R", "3-054R", "3-054R",
|
||||
"3-056H", "3-056H",
|
||||
"3-059C", "3-059C", "3-059C",
|
||||
"3-060C", "3-060C", "3-060C",
|
||||
"3-061R", "3-061R", "3-061R",
|
||||
"3-062C", "3-062C", "3-062C",
|
||||
"3-065C", "3-065C", "3-065C",
|
||||
"3-118C", "3-118C", "3-118C",
|
||||
"3-119C", "3-119C", "3-119C",
|
||||
"3-120C", "3-120C", "3-120C",
|
||||
"3-121R", "3-121R", "3-121R",
|
||||
"3-124R", "3-124R", "3-124R",
|
||||
"3-125R", "3-125R", "3-125R",
|
||||
"3-127R", "3-127R", "3-127R"
|
||||
"1-068C", "1-068C",
|
||||
"1-075C", "1-075C",
|
||||
"1-076C", "1-076C",
|
||||
"1-078C", "1-078C",
|
||||
"3-051R", "3-051R",
|
||||
"3-057R", "3-057R",
|
||||
"3-061R", "3-061R",
|
||||
"3-062C", "3-062C",
|
||||
"3-072R", "3-072R", "3-072R",
|
||||
"3-150S", "3-150S", "3-150S",
|
||||
"3-153S", "3-153S", "3-153S",
|
||||
"1-130C", "1-130C",
|
||||
"1-138C", "1-138C",
|
||||
"1-143C", "1-143C",
|
||||
"1-148C", "1-148C",
|
||||
"2-107C", "2-107C",
|
||||
"2-115C", "2-115C",
|
||||
"2-120C", "2-120C",
|
||||
"3-097R", "3-097R", "3-097R",
|
||||
"3-109C", "3-109C",
|
||||
"3-113R", "3-113R", "3-113R",
|
||||
"3-151S", "3-151S", "3-151S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -110,21 +130,28 @@
|
||||
"description": "Zidane, Vivi, and the FFIX cast",
|
||||
"image": "starter_decks/opus3_ix_fire_water.png",
|
||||
"cards": [
|
||||
"3-002R", "3-002R", "3-002R",
|
||||
"3-003C", "3-003C", "3-003C",
|
||||
"3-008C", "3-008C", "3-008C",
|
||||
"3-012R", "3-012R", "3-012R",
|
||||
"3-017C", "3-017C", "3-017C",
|
||||
"3-018C", "3-018C", "3-018C",
|
||||
"3-019R", "3-019R", "3-019R",
|
||||
"3-130H",
|
||||
"3-131R", "3-131R", "3-131R",
|
||||
"3-139C", "3-139C", "3-139C",
|
||||
"3-140C", "3-140C", "3-140C",
|
||||
"3-141R", "3-141R", "3-141R",
|
||||
"3-144L",
|
||||
"3-148R", "3-148R", "3-148R",
|
||||
"3-154C", "3-154C", "3-154C"
|
||||
"1-008C", "1-008C",
|
||||
"1-011C", "1-011C",
|
||||
"2-002C", "2-002C",
|
||||
"2-005C", "2-005C",
|
||||
"2-010C", "2-010C",
|
||||
"2-013C", "2-013C",
|
||||
"2-018C", "2-018C",
|
||||
"3-013R", "3-013R", "3-013R",
|
||||
"3-014C", "3-014C",
|
||||
"3-015R", "3-015R",
|
||||
"3-149S", "3-149S", "3-149S",
|
||||
"3-154S", "3-154S", "3-154S",
|
||||
"1-159C", "1-159C",
|
||||
"1-170C", "1-170C",
|
||||
"1-172C", "1-172C",
|
||||
"2-131C", "2-131C",
|
||||
"3-122C", "3-122C",
|
||||
"3-127R", "3-127R",
|
||||
"3-133C", "3-133C", "3-133C",
|
||||
"3-137R", "3-137R", "3-137R",
|
||||
"3-141C", "3-141C",
|
||||
"3-152S", "3-152S", "3-152S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -135,22 +162,28 @@
|
||||
"description": "Vaan, Ashe, and the FFXII cast",
|
||||
"image": "starter_decks/opus5_xii_wind_water.png",
|
||||
"cards": [
|
||||
"5-062R", "5-062R", "5-062R",
|
||||
"5-063H",
|
||||
"5-064C", "5-064C", "5-064C",
|
||||
"5-067C", "5-067C", "5-067C",
|
||||
"5-068C", "5-068C", "5-068C",
|
||||
"5-069C", "5-069C", "5-069C",
|
||||
"5-070C", "5-070C", "5-070C",
|
||||
"5-139L",
|
||||
"5-140C", "5-140C", "5-140C",
|
||||
"5-141C", "5-141C", "5-141C",
|
||||
"5-142R", "5-142R", "5-142R",
|
||||
"5-144R", "5-144R", "5-144R",
|
||||
"5-145H",
|
||||
"5-148C", "5-148C", "5-148C",
|
||||
"5-156C", "5-156C", "5-156C",
|
||||
"5-158C", "5-158C", "5-158C"
|
||||
"1-068C", "1-068C",
|
||||
"1-088C", "1-088C",
|
||||
"2-058C", "2-058C",
|
||||
"2-070R", "2-070R",
|
||||
"2-072C", "2-072C",
|
||||
"3-070C", "3-070C",
|
||||
"4-058C", "4-058C",
|
||||
"5-070C", "5-070C",
|
||||
"5-143C", "5-143C",
|
||||
"5-155S", "5-155S", "5-155S",
|
||||
"5-156S", "5-156S", "5-156S",
|
||||
"5-157S", "5-157S", "5-157S",
|
||||
"1-159C", "1-159C",
|
||||
"1-167C", "1-167C",
|
||||
"1-168C", "1-168C",
|
||||
"2-133R", "2-133R",
|
||||
"3-121C", "3-121C",
|
||||
"4-124C", "4-124C",
|
||||
"5-122R", "5-122R",
|
||||
"5-164S", "5-164S", "5-164S",
|
||||
"5-165S", "5-165S", "5-165S",
|
||||
"5-166S", "5-166S", "5-166S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -161,21 +194,28 @@
|
||||
"description": "Noel, Serah, and the FFXIII-2 cast",
|
||||
"image": "starter_decks/opus5_xiii2_ice_fire.png",
|
||||
"cards": [
|
||||
"5-019R", "5-019R", "5-019R",
|
||||
"5-022R", "5-022R", "5-022R",
|
||||
"5-024C", "5-024C", "5-024C",
|
||||
"5-027C", "5-027C", "5-027C",
|
||||
"5-029R", "5-029R", "5-029R",
|
||||
"5-032R", "5-032R", "5-032R",
|
||||
"5-034C", "5-034C", "5-034C",
|
||||
"5-001C", "5-001C", "5-001C",
|
||||
"5-002R", "5-002R", "5-002R",
|
||||
"5-008H",
|
||||
"5-010C", "5-010C", "5-010C",
|
||||
"5-012R", "5-012R", "5-012R",
|
||||
"5-015C", "5-015C", "5-015C",
|
||||
"5-017C", "5-017C", "5-017C",
|
||||
"5-018H"
|
||||
"1-010C", "1-010C",
|
||||
"1-011C", "1-011C",
|
||||
"1-023R", "1-023R",
|
||||
"1-035C", "1-035C",
|
||||
"2-006R", "2-006R",
|
||||
"4-019C", "4-019C",
|
||||
"5-005R", "5-005R",
|
||||
"5-149S", "5-149S", "5-149S",
|
||||
"5-150S", "5-150S", "5-150S",
|
||||
"5-151S", "5-151S", "5-151S",
|
||||
"1-038R", "1-038R",
|
||||
"1-040C", "1-040C",
|
||||
"1-050C", "1-050C",
|
||||
"2-021C", "2-021C",
|
||||
"2-043C", "2-043C",
|
||||
"4-029C", "4-029C",
|
||||
"4-040C", "4-040C",
|
||||
"5-004R", "5-004R",
|
||||
"5-038C", "5-038C",
|
||||
"5-152S", "5-152S", "5-152S",
|
||||
"5-153S", "5-153S", "5-153S",
|
||||
"5-154S", "5-154S", "5-154S"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -186,21 +226,28 @@
|
||||
"description": "Warriors of Light from FFXIV",
|
||||
"image": "starter_decks/opus5_xiv_earth_lightning.png",
|
||||
"cards": [
|
||||
"5-075C", "5-075C", "5-075C",
|
||||
"5-076C", "5-076C", "5-076C",
|
||||
"5-078C", "5-078C", "5-078C",
|
||||
"5-080R", "5-080R", "5-080R",
|
||||
"5-085C", "5-085C", "5-085C",
|
||||
"5-086R", "5-086R", "5-086R",
|
||||
"5-091H",
|
||||
"5-094C", "5-094C", "5-094C",
|
||||
"5-096H",
|
||||
"5-097C", "5-097C", "5-097C",
|
||||
"5-099C", "5-099C", "5-099C",
|
||||
"5-101R", "5-101R", "5-101R",
|
||||
"5-103C", "5-103C", "5-103C",
|
||||
"5-104R", "5-104R", "5-104R",
|
||||
"5-112C", "5-112C", "5-112C"
|
||||
"1-092C", "1-092C",
|
||||
"1-099C", "1-099C",
|
||||
"1-106C", "1-106C",
|
||||
"1-120C", "1-120C",
|
||||
"2-074C", "2-074C",
|
||||
"2-080C", "2-080C",
|
||||
"3-084C", "3-084C",
|
||||
"4-078C", "4-078C",
|
||||
"5-082C", "5-082C",
|
||||
"5-158S", "5-158S", "5-158S",
|
||||
"5-159S", "5-159S", "5-159S",
|
||||
"5-160S", "5-160S", "5-160S",
|
||||
"1-130C", "1-130C",
|
||||
"1-138C", "1-138C",
|
||||
"1-143C", "1-143C",
|
||||
"1-148C", "1-148C",
|
||||
"2-107C", "2-107C",
|
||||
"3-104C", "3-104C",
|
||||
"4-106C", "4-106C",
|
||||
"5-161S", "5-161S", "5-161S",
|
||||
"5-162S", "5-162S", "5-162S",
|
||||
"5-163S", "5-163S", "5-163S"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -21,6 +21,8 @@ config/icon="res://assets/ui/icon.svg"
|
||||
|
||||
GameManager="*res://scripts/autoload/GameManager.gd"
|
||||
CardDatabase="*res://scripts/autoload/CardDatabase.gd"
|
||||
NetworkManager="*res://scripts/network/NetworkManager.gd"
|
||||
AbilitySystem="*res://scripts/game/abilities/AbilitySystem.gd"
|
||||
|
||||
[display]
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ enum State {
|
||||
DECK_BUILDER,
|
||||
GAME_SETUP,
|
||||
PLAYING,
|
||||
PAUSED
|
||||
PAUSED,
|
||||
LOGIN,
|
||||
REGISTER,
|
||||
ONLINE_LOBBY,
|
||||
ONLINE_GAME,
|
||||
PROFILE,
|
||||
LEADERBOARD
|
||||
}
|
||||
|
||||
var current_state: State = State.MENU
|
||||
@@ -20,6 +26,16 @@ const DECK_BUILDER_SIZE := Vector2i(1600, 900)
|
||||
const GAME_SETUP_SIZE := Vector2i(800, 600)
|
||||
# Game window size
|
||||
const GAME_SIZE := Vector2i(2160, 980)
|
||||
# Login screen size
|
||||
const LOGIN_SIZE := Vector2i(400, 500)
|
||||
# Register screen size
|
||||
const REGISTER_SIZE := Vector2i(400, 600)
|
||||
# Online lobby size
|
||||
const ONLINE_LOBBY_SIZE := Vector2i(600, 700)
|
||||
# Profile screen size
|
||||
const PROFILE_SIZE := Vector2i(600, 700)
|
||||
# Leaderboard screen size
|
||||
const LEADERBOARD_SIZE := Vector2i(600, 700)
|
||||
|
||||
# Scene references
|
||||
var main_menu: MainMenu = null
|
||||
@@ -27,12 +43,26 @@ var deck_builder: DeckBuilder = null
|
||||
var game_setup_menu: GameSetupMenu = null
|
||||
var game_scene: Node3D = null
|
||||
var pause_menu: PauseMenu = null
|
||||
var login_screen: LoginScreen = null
|
||||
var register_screen: RegisterScreen = null
|
||||
var online_lobby: OnlineLobby = null
|
||||
var profile_screen: ProfileScreen = null
|
||||
var leaderboard_screen: LeaderboardScreen = null
|
||||
|
||||
# Selected decks for gameplay
|
||||
var selected_deck: Deck = null
|
||||
var player1_deck: Array = [] # Card IDs for player 1
|
||||
var player2_deck: Array = [] # Card IDs for player 2
|
||||
|
||||
# AI settings
|
||||
var is_vs_ai: bool = false
|
||||
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL
|
||||
|
||||
# Online game settings
|
||||
var is_online_game: bool = false
|
||||
var online_game_data: Dictionary = {}
|
||||
var online_pause_menu: Control = null
|
||||
|
||||
# Preload the main game scene script
|
||||
const MainScript = preload("res://scripts/Main.gd")
|
||||
|
||||
@@ -52,6 +82,18 @@ func _input(event: InputEvent) -> void:
|
||||
_show_pause_menu()
|
||||
State.PAUSED:
|
||||
_hide_pause_menu()
|
||||
State.LOGIN:
|
||||
_on_login_back()
|
||||
State.REGISTER:
|
||||
_on_register_back()
|
||||
State.ONLINE_LOBBY:
|
||||
_on_online_lobby_back()
|
||||
State.ONLINE_GAME:
|
||||
_show_online_pause_menu()
|
||||
State.PROFILE:
|
||||
_on_profile_back()
|
||||
State.LEADERBOARD:
|
||||
_on_leaderboard_back()
|
||||
|
||||
func _show_main_menu() -> void:
|
||||
# Clean up any existing game
|
||||
@@ -86,12 +128,37 @@ func _show_main_menu() -> void:
|
||||
game_setup_menu.queue_free()
|
||||
game_setup_menu = null
|
||||
|
||||
# Create main menu
|
||||
# Clean up login screen if exists
|
||||
if login_screen:
|
||||
login_screen.queue_free()
|
||||
login_screen = null
|
||||
|
||||
# Clean up register screen if exists
|
||||
if register_screen:
|
||||
register_screen.queue_free()
|
||||
register_screen = null
|
||||
|
||||
# Clean up online lobby if exists
|
||||
if online_lobby:
|
||||
online_lobby.queue_free()
|
||||
online_lobby = null
|
||||
|
||||
# Clean up profile screen if exists
|
||||
if profile_screen:
|
||||
profile_screen.queue_free()
|
||||
profile_screen = null
|
||||
|
||||
# Clean up leaderboard screen if exists
|
||||
if leaderboard_screen:
|
||||
leaderboard_screen.queue_free()
|
||||
leaderboard_screen = null
|
||||
|
||||
if not main_menu:
|
||||
main_menu = MainMenu.new()
|
||||
add_child(main_menu)
|
||||
main_menu.play_game.connect(_on_start_game)
|
||||
main_menu.deck_builder.connect(_on_deck_builder)
|
||||
main_menu.online_game.connect(_on_online_game)
|
||||
|
||||
main_menu.visible = true
|
||||
current_state = State.MENU
|
||||
@@ -169,9 +236,11 @@ func _on_game_setup_back() -> void:
|
||||
_show_main_menu()
|
||||
|
||||
|
||||
func _on_game_setup_start(p1_deck: Array, p2_deck: Array) -> void:
|
||||
func _on_game_setup_start(p1_deck: Array, p2_deck: Array, p_is_vs_ai: bool = false, p_ai_difficulty: int = AIStrategy.Difficulty.NORMAL) -> void:
|
||||
player1_deck = p1_deck
|
||||
player2_deck = p2_deck
|
||||
is_vs_ai = p_is_vs_ai
|
||||
ai_difficulty = p_ai_difficulty
|
||||
|
||||
if game_setup_menu:
|
||||
game_setup_menu.visible = false
|
||||
@@ -234,6 +303,10 @@ func _start_new_game() -> void:
|
||||
if player2_deck.size() > 0:
|
||||
game_scene.player2_deck = player2_deck
|
||||
|
||||
# Pass AI configuration
|
||||
game_scene.is_vs_ai = is_vs_ai
|
||||
game_scene.ai_difficulty = ai_difficulty
|
||||
|
||||
add_child(game_scene)
|
||||
|
||||
# Create pause menu
|
||||
@@ -264,3 +337,405 @@ func _on_restart_game() -> void:
|
||||
|
||||
func _on_return_to_menu() -> void:
|
||||
_show_main_menu()
|
||||
|
||||
|
||||
# ======= ONLINE PLAY =======
|
||||
|
||||
func _on_online_game() -> void:
|
||||
# Hide menu
|
||||
if main_menu:
|
||||
main_menu.visible = false
|
||||
|
||||
# Check if already authenticated
|
||||
if NetworkManager and NetworkManager.is_authenticated:
|
||||
_show_online_lobby()
|
||||
else:
|
||||
_show_login_screen()
|
||||
|
||||
|
||||
func _show_login_screen() -> void:
|
||||
# Switch to login screen window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_size(LOGIN_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - LOGIN_SIZE.x) / 2,
|
||||
(screen.y - LOGIN_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to login size
|
||||
get_tree().root.content_scale_size = LOGIN_SIZE
|
||||
|
||||
# Create login screen
|
||||
if not login_screen:
|
||||
login_screen = LoginScreen.new()
|
||||
add_child(login_screen)
|
||||
login_screen.login_successful.connect(_on_login_successful)
|
||||
login_screen.register_requested.connect(_on_register_requested)
|
||||
login_screen.back_pressed.connect(_on_login_back)
|
||||
|
||||
login_screen.visible = true
|
||||
login_screen.focus_email()
|
||||
current_state = State.LOGIN
|
||||
|
||||
|
||||
func _on_login_back() -> void:
|
||||
if login_screen:
|
||||
login_screen.visible = false
|
||||
login_screen.clear_form()
|
||||
_show_main_menu()
|
||||
|
||||
|
||||
func _on_login_successful(_user_data: Dictionary) -> void:
|
||||
if login_screen:
|
||||
login_screen.visible = false
|
||||
login_screen.clear_form()
|
||||
_show_online_lobby()
|
||||
|
||||
|
||||
func _on_register_requested() -> void:
|
||||
if login_screen:
|
||||
login_screen.visible = false
|
||||
login_screen.clear_form()
|
||||
_show_register_screen()
|
||||
|
||||
|
||||
func _show_register_screen() -> void:
|
||||
# Switch to register screen window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_size(REGISTER_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - REGISTER_SIZE.x) / 2,
|
||||
(screen.y - REGISTER_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to register size
|
||||
get_tree().root.content_scale_size = REGISTER_SIZE
|
||||
|
||||
# Create register screen
|
||||
if not register_screen:
|
||||
register_screen = RegisterScreen.new()
|
||||
add_child(register_screen)
|
||||
register_screen.registration_successful.connect(_on_registration_successful)
|
||||
register_screen.login_requested.connect(_on_login_from_register)
|
||||
register_screen.back_pressed.connect(_on_register_back)
|
||||
|
||||
register_screen.visible = true
|
||||
register_screen.focus_email()
|
||||
current_state = State.REGISTER
|
||||
|
||||
|
||||
func _on_register_back() -> void:
|
||||
if register_screen:
|
||||
register_screen.visible = false
|
||||
register_screen.clear_form()
|
||||
_show_main_menu()
|
||||
|
||||
|
||||
func _on_registration_successful(_message: String) -> void:
|
||||
# After successful registration, show login screen
|
||||
if register_screen:
|
||||
register_screen.visible = false
|
||||
register_screen.clear_form()
|
||||
_show_login_screen()
|
||||
|
||||
|
||||
func _on_login_from_register() -> void:
|
||||
if register_screen:
|
||||
register_screen.visible = false
|
||||
register_screen.clear_form()
|
||||
_show_login_screen()
|
||||
|
||||
|
||||
func _show_online_lobby() -> void:
|
||||
# Switch to online lobby window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_size(ONLINE_LOBBY_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - ONLINE_LOBBY_SIZE.x) / 2,
|
||||
(screen.y - ONLINE_LOBBY_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to online lobby size
|
||||
get_tree().root.content_scale_size = ONLINE_LOBBY_SIZE
|
||||
|
||||
# Create online lobby
|
||||
if not online_lobby:
|
||||
online_lobby = OnlineLobby.new()
|
||||
add_child(online_lobby)
|
||||
online_lobby.back_pressed.connect(_on_online_lobby_back)
|
||||
online_lobby.game_starting.connect(_on_online_game_starting)
|
||||
online_lobby.profile_requested.connect(_show_profile_screen)
|
||||
online_lobby.leaderboard_requested.connect(_show_leaderboard_screen)
|
||||
|
||||
# Connect to game_ended signal for handling online game completion
|
||||
if NetworkManager and not NetworkManager.game_ended.is_connected(_on_online_game_ended):
|
||||
NetworkManager.game_ended.connect(_on_online_game_ended)
|
||||
|
||||
online_lobby.visible = true
|
||||
current_state = State.ONLINE_LOBBY
|
||||
|
||||
|
||||
func _on_online_lobby_back() -> void:
|
||||
if online_lobby:
|
||||
online_lobby.visible = false
|
||||
_show_main_menu()
|
||||
|
||||
|
||||
func _on_online_game_starting(game_data: Dictionary) -> void:
|
||||
if online_lobby:
|
||||
online_lobby.visible = false
|
||||
|
||||
# Store game data
|
||||
online_game_data = game_data
|
||||
is_online_game = true
|
||||
|
||||
var opponent = game_data.get("opponent", {})
|
||||
print("Starting online game against: ", opponent.get("username", "Unknown"))
|
||||
print("Game ID: ", game_data.get("game_id", ""))
|
||||
print("Local player index: ", game_data.get("your_player_index", 0))
|
||||
print("First player: ", game_data.get("first_player", 0))
|
||||
|
||||
# Switch to game window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
|
||||
DisplayServer.window_set_size(GAME_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - GAME_SIZE.x) / 2,
|
||||
(screen.y - GAME_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to game size
|
||||
get_tree().root.content_scale_size = GAME_SIZE
|
||||
|
||||
_start_online_game(game_data)
|
||||
|
||||
|
||||
func _start_online_game(game_data: Dictionary) -> void:
|
||||
# Make sure game isn't paused
|
||||
get_tree().paused = false
|
||||
|
||||
# Clean up existing game
|
||||
if game_scene:
|
||||
game_scene.queue_free()
|
||||
game_scene = null
|
||||
|
||||
# Reset GameManager state
|
||||
if GameManager:
|
||||
GameManager.is_game_active = false
|
||||
GameManager.game_state = null
|
||||
|
||||
# Create new game scene
|
||||
game_scene = Node3D.new()
|
||||
game_scene.set_script(MainScript)
|
||||
|
||||
# Mark as online game
|
||||
game_scene.is_online_game = true
|
||||
|
||||
# Get the deck ID from NetworkManager (set when joining queue/room)
|
||||
var deck_id = game_data.get("deck_id", "")
|
||||
var local_player_index = game_data.get("your_player_index", 0)
|
||||
|
||||
# Load the selected deck for the local player
|
||||
var local_deck = _load_deck_by_id(deck_id)
|
||||
|
||||
# For online games, player positions are swapped based on index
|
||||
# The local player is always displayed on the bottom (player 1 position visually)
|
||||
# But the game logic uses the server-assigned indices
|
||||
if local_player_index == 0:
|
||||
game_scene.player1_deck = local_deck
|
||||
game_scene.player2_deck = [] # Opponent's deck is hidden
|
||||
else:
|
||||
game_scene.player1_deck = [] # Opponent's deck is hidden
|
||||
game_scene.player2_deck = local_deck
|
||||
|
||||
# Pass game configuration
|
||||
game_scene.is_vs_ai = false
|
||||
game_scene.online_game_data = game_data
|
||||
|
||||
add_child(game_scene)
|
||||
|
||||
# Setup online game specifics after scene is added
|
||||
game_scene.setup_online_game(game_data)
|
||||
|
||||
# Create online pause menu (no restart option, has forfeit)
|
||||
_create_online_pause_menu()
|
||||
|
||||
current_state = State.ONLINE_GAME
|
||||
|
||||
|
||||
func _load_deck_by_id(deck_id: String) -> Array:
|
||||
# Load deck from CardDatabase saved decks or starter decks
|
||||
if deck_id.is_empty():
|
||||
# Use default starter deck
|
||||
return CardDatabase.get_starter_deck_ids("Fire Starter")
|
||||
|
||||
# Check saved decks
|
||||
var saved_decks = CardDatabase.get_saved_decks()
|
||||
for deck in saved_decks:
|
||||
if deck.get("id", "") == deck_id:
|
||||
return deck.get("card_ids", [])
|
||||
|
||||
# Check starter decks
|
||||
var starter_decks = CardDatabase.get_starter_decks()
|
||||
for deck in starter_decks:
|
||||
if deck.get("id", "") == deck_id:
|
||||
return deck.get("card_ids", [])
|
||||
|
||||
# Fallback to first starter deck
|
||||
return CardDatabase.get_starter_deck_ids("Fire Starter")
|
||||
|
||||
|
||||
func _create_online_pause_menu() -> void:
|
||||
if online_pause_menu:
|
||||
online_pause_menu.queue_free()
|
||||
|
||||
online_pause_menu = Control.new()
|
||||
online_pause_menu.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
online_pause_menu.visible = false
|
||||
|
||||
# Semi-transparent background
|
||||
var bg = ColorRect.new()
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.color = Color(0, 0, 0, 0.7)
|
||||
online_pause_menu.add_child(bg)
|
||||
|
||||
# Menu container
|
||||
var container = VBoxContainer.new()
|
||||
container.set_anchors_preset(Control.PRESET_CENTER)
|
||||
container.custom_minimum_size = Vector2(300, 200)
|
||||
container.add_theme_constant_override("separation", 20)
|
||||
online_pause_menu.add_child(container)
|
||||
|
||||
# Title
|
||||
var title = Label.new()
|
||||
title.text = "PAUSED"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.add_theme_font_size_override("font_size", 32)
|
||||
container.add_child(title)
|
||||
|
||||
# Resume button
|
||||
var resume_btn = Button.new()
|
||||
resume_btn.text = "Resume"
|
||||
resume_btn.custom_minimum_size = Vector2(200, 50)
|
||||
resume_btn.pressed.connect(_hide_online_pause_menu)
|
||||
container.add_child(resume_btn)
|
||||
|
||||
# Forfeit button
|
||||
var forfeit_btn = Button.new()
|
||||
forfeit_btn.text = "Forfeit Game"
|
||||
forfeit_btn.custom_minimum_size = Vector2(200, 50)
|
||||
forfeit_btn.pressed.connect(_on_forfeit_game)
|
||||
container.add_child(forfeit_btn)
|
||||
|
||||
add_child(online_pause_menu)
|
||||
|
||||
|
||||
func _show_online_pause_menu() -> void:
|
||||
if online_pause_menu:
|
||||
online_pause_menu.visible = true
|
||||
get_tree().paused = true
|
||||
|
||||
|
||||
func _hide_online_pause_menu() -> void:
|
||||
if online_pause_menu:
|
||||
online_pause_menu.visible = false
|
||||
get_tree().paused = false
|
||||
|
||||
|
||||
func _on_forfeit_game() -> void:
|
||||
# Send concede to server
|
||||
if NetworkManager:
|
||||
NetworkManager.send_concede()
|
||||
|
||||
_hide_online_pause_menu()
|
||||
# Game end will be handled by the game_ended signal
|
||||
|
||||
|
||||
func _on_online_game_ended(_result: Dictionary) -> void:
|
||||
is_online_game = false
|
||||
online_game_data = {}
|
||||
|
||||
if online_pause_menu:
|
||||
online_pause_menu.queue_free()
|
||||
online_pause_menu = null
|
||||
|
||||
# Return to online lobby after a delay
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
_show_online_lobby()
|
||||
|
||||
|
||||
# ======= PROFILE SCREEN =======
|
||||
|
||||
func _show_profile_screen() -> void:
|
||||
# Hide online lobby
|
||||
if online_lobby:
|
||||
online_lobby.visible = false
|
||||
|
||||
# Switch to profile screen window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_size(PROFILE_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - PROFILE_SIZE.x) / 2,
|
||||
(screen.y - PROFILE_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to profile size
|
||||
get_tree().root.content_scale_size = PROFILE_SIZE
|
||||
|
||||
# Create profile screen
|
||||
if not profile_screen:
|
||||
profile_screen = ProfileScreen.new()
|
||||
add_child(profile_screen)
|
||||
profile_screen.back_pressed.connect(_on_profile_back)
|
||||
else:
|
||||
profile_screen.refresh()
|
||||
|
||||
profile_screen.visible = true
|
||||
current_state = State.PROFILE
|
||||
|
||||
|
||||
func _on_profile_back() -> void:
|
||||
if profile_screen:
|
||||
profile_screen.visible = false
|
||||
_show_online_lobby()
|
||||
|
||||
|
||||
# ======= LEADERBOARD SCREEN =======
|
||||
|
||||
func _show_leaderboard_screen() -> void:
|
||||
# Hide online lobby
|
||||
if online_lobby:
|
||||
online_lobby.visible = false
|
||||
|
||||
# Switch to leaderboard screen window size
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
DisplayServer.window_set_size(LEADERBOARD_SIZE)
|
||||
var screen := DisplayServer.screen_get_size()
|
||||
DisplayServer.window_set_position(Vector2i(
|
||||
(screen.x - LEADERBOARD_SIZE.x) / 2,
|
||||
(screen.y - LEADERBOARD_SIZE.y) / 2
|
||||
))
|
||||
|
||||
# Set viewport to leaderboard size
|
||||
get_tree().root.content_scale_size = LEADERBOARD_SIZE
|
||||
|
||||
# Create leaderboard screen
|
||||
if not leaderboard_screen:
|
||||
leaderboard_screen = LeaderboardScreen.new()
|
||||
add_child(leaderboard_screen)
|
||||
leaderboard_screen.back_pressed.connect(_on_leaderboard_back)
|
||||
else:
|
||||
leaderboard_screen.refresh()
|
||||
|
||||
leaderboard_screen.visible = true
|
||||
current_state = State.LEADERBOARD
|
||||
|
||||
|
||||
func _on_leaderboard_back() -> void:
|
||||
if leaderboard_screen:
|
||||
leaderboard_screen.visible = false
|
||||
_show_online_lobby()
|
||||
|
||||
478
scripts/Main.gd
478
scripts/Main.gd
@@ -8,6 +8,7 @@ var game_ui: GameUI
|
||||
var hand_display: HandDisplay
|
||||
var hand_layer: CanvasLayer
|
||||
var action_log: ActionLog
|
||||
var choice_modal: ChoiceModal
|
||||
|
||||
# Player damage displays
|
||||
var damage_displays: Array[DamageDisplay] = []
|
||||
@@ -16,6 +17,20 @@ var damage_displays: Array[DamageDisplay] = []
|
||||
var player1_deck: Array = []
|
||||
var player2_deck: Array = []
|
||||
|
||||
# AI settings (set by GameController before game starts)
|
||||
var is_vs_ai: bool = false
|
||||
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL
|
||||
var ai_controller: AIController = null
|
||||
var ai_player_index: int = 1 # AI is always Player 2
|
||||
|
||||
# Online game settings (set by GameController before game starts)
|
||||
var is_online_game: bool = false
|
||||
var online_game_data: Dictionary = {}
|
||||
var local_player_index: int = 0
|
||||
var online_game_id: String = ""
|
||||
var opponent_info: Dictionary = {}
|
||||
var turn_timer_label: Label = null
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_table()
|
||||
_setup_ui()
|
||||
@@ -93,6 +108,14 @@ func _setup_ui() -> void:
|
||||
var damage_display = DamageDisplay.new()
|
||||
damage_displays.append(damage_display)
|
||||
|
||||
# Choice modal for multi-modal ability choices (layer 200 - highest priority)
|
||||
choice_modal = ChoiceModal.new()
|
||||
add_child(choice_modal)
|
||||
|
||||
# Connect choice modal to AbilitySystem autoload
|
||||
if AbilitySystem:
|
||||
AbilitySystem.choice_modal = choice_modal
|
||||
|
||||
func _position_hand_display() -> void:
|
||||
var viewport = get_viewport()
|
||||
if viewport:
|
||||
@@ -136,11 +159,383 @@ func _connect_signals() -> void:
|
||||
# Field card action signal (deferred to ensure game_ui is ready)
|
||||
call_deferred("_connect_field_card_signals")
|
||||
|
||||
# Connect attack signal for AI blocking (deferred to ensure game_state exists)
|
||||
call_deferred("_connect_attack_signal")
|
||||
|
||||
|
||||
# ======= ONLINE GAME SETUP =======
|
||||
|
||||
func setup_online_game(game_data: Dictionary) -> void:
|
||||
is_online_game = true
|
||||
online_game_data = game_data
|
||||
online_game_id = game_data.get("game_id", "")
|
||||
local_player_index = game_data.get("your_player_index", 0)
|
||||
opponent_info = game_data.get("opponent", {})
|
||||
|
||||
print("Setting up online game: ", online_game_id)
|
||||
print("Local player index: ", local_player_index)
|
||||
print("Opponent: ", opponent_info.get("username", "Unknown"))
|
||||
|
||||
# Connect network signals
|
||||
_connect_network_signals()
|
||||
|
||||
# Create turn timer UI
|
||||
_create_turn_timer_ui()
|
||||
|
||||
# Create opponent info UI
|
||||
_create_opponent_info_ui()
|
||||
|
||||
|
||||
func _connect_network_signals() -> void:
|
||||
if not NetworkManager:
|
||||
return
|
||||
|
||||
# Game events
|
||||
if not NetworkManager.opponent_action_received.is_connected(_on_opponent_action):
|
||||
NetworkManager.opponent_action_received.connect(_on_opponent_action)
|
||||
|
||||
if not NetworkManager.turn_timer_update.is_connected(_on_turn_timer_update):
|
||||
NetworkManager.turn_timer_update.connect(_on_turn_timer_update)
|
||||
|
||||
if not NetworkManager.phase_changed.is_connected(_on_network_phase_changed):
|
||||
NetworkManager.phase_changed.connect(_on_network_phase_changed)
|
||||
|
||||
if not NetworkManager.action_confirmed.is_connected(_on_action_confirmed):
|
||||
NetworkManager.action_confirmed.connect(_on_action_confirmed)
|
||||
|
||||
if not NetworkManager.action_failed.is_connected(_on_action_failed):
|
||||
NetworkManager.action_failed.connect(_on_action_failed)
|
||||
|
||||
if not NetworkManager.opponent_disconnected.is_connected(_on_opponent_disconnected):
|
||||
NetworkManager.opponent_disconnected.connect(_on_opponent_disconnected)
|
||||
|
||||
if not NetworkManager.opponent_reconnected.is_connected(_on_opponent_reconnected):
|
||||
NetworkManager.opponent_reconnected.connect(_on_opponent_reconnected)
|
||||
|
||||
if not NetworkManager.game_state_sync.is_connected(_on_game_state_sync):
|
||||
NetworkManager.game_state_sync.connect(_on_game_state_sync)
|
||||
|
||||
|
||||
func _disconnect_network_signals() -> void:
|
||||
if not NetworkManager:
|
||||
return
|
||||
|
||||
if NetworkManager.opponent_action_received.is_connected(_on_opponent_action):
|
||||
NetworkManager.opponent_action_received.disconnect(_on_opponent_action)
|
||||
|
||||
if NetworkManager.turn_timer_update.is_connected(_on_turn_timer_update):
|
||||
NetworkManager.turn_timer_update.disconnect(_on_turn_timer_update)
|
||||
|
||||
if NetworkManager.phase_changed.is_connected(_on_network_phase_changed):
|
||||
NetworkManager.phase_changed.disconnect(_on_network_phase_changed)
|
||||
|
||||
if NetworkManager.action_confirmed.is_connected(_on_action_confirmed):
|
||||
NetworkManager.action_confirmed.disconnect(_on_action_confirmed)
|
||||
|
||||
if NetworkManager.action_failed.is_connected(_on_action_failed):
|
||||
NetworkManager.action_failed.disconnect(_on_action_failed)
|
||||
|
||||
if NetworkManager.opponent_disconnected.is_connected(_on_opponent_disconnected):
|
||||
NetworkManager.opponent_disconnected.disconnect(_on_opponent_disconnected)
|
||||
|
||||
if NetworkManager.opponent_reconnected.is_connected(_on_opponent_reconnected):
|
||||
NetworkManager.opponent_reconnected.disconnect(_on_opponent_reconnected)
|
||||
|
||||
if NetworkManager.game_state_sync.is_connected(_on_game_state_sync):
|
||||
NetworkManager.game_state_sync.disconnect(_on_game_state_sync)
|
||||
|
||||
|
||||
func _create_turn_timer_ui() -> void:
|
||||
# Create turn timer label
|
||||
turn_timer_label = Label.new()
|
||||
turn_timer_label.text = "2:00"
|
||||
turn_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
turn_timer_label.add_theme_font_size_override("font_size", 28)
|
||||
turn_timer_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
|
||||
# Position at top center
|
||||
turn_timer_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
turn_timer_label.offset_top = 10
|
||||
turn_timer_label.offset_bottom = 50
|
||||
turn_timer_label.offset_left = -50
|
||||
turn_timer_label.offset_right = 50
|
||||
|
||||
if game_ui:
|
||||
game_ui.add_child(turn_timer_label)
|
||||
|
||||
|
||||
func _create_opponent_info_ui() -> void:
|
||||
# Create opponent info label (next to timer)
|
||||
var opponent_label = Label.new()
|
||||
opponent_label.name = "OpponentInfo"
|
||||
opponent_label.text = "vs %s (ELO: %d)" % [
|
||||
opponent_info.get("username", "Unknown"),
|
||||
opponent_info.get("elo", 1000)
|
||||
]
|
||||
opponent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
opponent_label.add_theme_font_size_override("font_size", 18)
|
||||
opponent_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
|
||||
# Position below timer
|
||||
opponent_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
opponent_label.offset_top = 45
|
||||
opponent_label.offset_bottom = 70
|
||||
opponent_label.offset_left = -150
|
||||
opponent_label.offset_right = 150
|
||||
|
||||
if game_ui:
|
||||
game_ui.add_child(opponent_label)
|
||||
|
||||
|
||||
# ======= ONLINE GAME HELPERS =======
|
||||
|
||||
func _is_local_player_turn() -> bool:
|
||||
if not is_online_game:
|
||||
return true
|
||||
if not GameManager.game_state:
|
||||
return true
|
||||
return GameManager.game_state.turn_manager.current_player_index == local_player_index
|
||||
|
||||
|
||||
func _can_perform_local_action() -> bool:
|
||||
if not is_online_game:
|
||||
return true
|
||||
return _is_local_player_turn()
|
||||
|
||||
|
||||
# ======= NETWORK EVENT HANDLERS =======
|
||||
|
||||
func _on_opponent_action(action_data: Dictionary) -> void:
|
||||
var action_type = action_data.get("action_type", "")
|
||||
var payload = action_data.get("payload", {})
|
||||
|
||||
print("Received opponent action: ", action_type)
|
||||
|
||||
match action_type:
|
||||
"play_card":
|
||||
_apply_opponent_play_card(payload)
|
||||
"attack":
|
||||
_apply_opponent_attack(payload)
|
||||
"block":
|
||||
_apply_opponent_block(payload)
|
||||
"pass":
|
||||
_apply_opponent_pass()
|
||||
"discard_cp":
|
||||
_apply_opponent_discard_cp(payload)
|
||||
"dull_backup_cp":
|
||||
_apply_opponent_dull_backup_cp(payload)
|
||||
"attack_resolved":
|
||||
_apply_opponent_attack_resolved()
|
||||
_:
|
||||
print("Unknown opponent action: ", action_type)
|
||||
|
||||
# Update visuals after opponent action
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
_update_cp_display()
|
||||
|
||||
|
||||
func _apply_opponent_play_card(payload: Dictionary) -> void:
|
||||
var card_instance_id = payload.get("card_instance_id", 0)
|
||||
# Find the card in opponent's hand and play it
|
||||
# For online, we trust the server - the opponent's hand is hidden anyway
|
||||
# Just sync visuals when server confirms
|
||||
print("Opponent played card: ", card_instance_id)
|
||||
|
||||
|
||||
func _apply_opponent_attack(payload: Dictionary) -> void:
|
||||
var attacker_instance_id = payload.get("attacker_instance_id", 0)
|
||||
print("Opponent declared attack with card: ", attacker_instance_id)
|
||||
# Find attacker and apply attack declaration
|
||||
# The server will handle phase changes
|
||||
|
||||
|
||||
func _apply_opponent_block(payload: Dictionary) -> void:
|
||||
var blocker_instance_id = payload.get("blocker_instance_id", null)
|
||||
if blocker_instance_id == null:
|
||||
print("Opponent chose not to block")
|
||||
else:
|
||||
print("Opponent declared block with card: ", blocker_instance_id)
|
||||
|
||||
|
||||
func _apply_opponent_pass() -> void:
|
||||
print("Opponent passed priority")
|
||||
# Server handles phase advancement
|
||||
|
||||
|
||||
func _apply_opponent_discard_cp(payload: Dictionary) -> void:
|
||||
var card_instance_id = payload.get("card_instance_id", 0)
|
||||
print("Opponent discarded card for CP: ", card_instance_id)
|
||||
|
||||
|
||||
func _apply_opponent_dull_backup_cp(payload: Dictionary) -> void:
|
||||
var card_instance_id = payload.get("card_instance_id", 0)
|
||||
print("Opponent dulled backup for CP: ", card_instance_id)
|
||||
|
||||
|
||||
func _apply_opponent_attack_resolved() -> void:
|
||||
print("Attack resolved")
|
||||
|
||||
|
||||
func _on_turn_timer_update(seconds_remaining: int) -> void:
|
||||
if turn_timer_label:
|
||||
var minutes = seconds_remaining / 60
|
||||
var secs = seconds_remaining % 60
|
||||
turn_timer_label.text = "%d:%02d" % [minutes, secs]
|
||||
|
||||
# Color based on time remaining
|
||||
if seconds_remaining <= 10:
|
||||
turn_timer_label.add_theme_color_override("font_color", Color.RED)
|
||||
elif seconds_remaining <= 30:
|
||||
turn_timer_label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
else:
|
||||
turn_timer_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
|
||||
|
||||
func _on_network_phase_changed(phase_data: Dictionary) -> void:
|
||||
var phase = phase_data.get("phase", 0)
|
||||
var current_player_index = phase_data.get("current_player_index", 0)
|
||||
var turn_number = phase_data.get("turn_number", 1)
|
||||
|
||||
# Validate player index bounds
|
||||
if current_player_index < 0 or current_player_index > 1:
|
||||
push_error("Invalid player index from server: %d" % current_player_index)
|
||||
return
|
||||
|
||||
print("Network phase changed: phase=", phase, " player=", current_player_index, " turn=", turn_number)
|
||||
|
||||
# Update local game state to match server
|
||||
if GameManager.game_state:
|
||||
GameManager.game_state.turn_manager.current_player_index = current_player_index
|
||||
GameManager.game_state.turn_manager.turn_number = turn_number
|
||||
# Phase enum values should match between server and client
|
||||
GameManager.game_state.turn_manager.current_phase = phase
|
||||
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
_update_cp_display()
|
||||
|
||||
# Switch camera to current player
|
||||
if table_setup:
|
||||
table_setup.switch_camera_to_player(current_player_index)
|
||||
|
||||
|
||||
func _on_action_confirmed(action_type: String) -> void:
|
||||
print("Action confirmed by server: ", action_type)
|
||||
|
||||
|
||||
func _on_action_failed(action_type: String, error: String) -> void:
|
||||
print("Action failed: ", action_type, " - ", error)
|
||||
game_ui.show_message("Action failed: " + error)
|
||||
|
||||
|
||||
func _on_opponent_disconnected(reconnect_timeout: int) -> void:
|
||||
game_ui.show_message("Opponent disconnected. Waiting %d seconds for reconnect..." % reconnect_timeout)
|
||||
|
||||
|
||||
func _on_opponent_reconnected() -> void:
|
||||
game_ui.show_message("Opponent reconnected!")
|
||||
# Hide the message after a moment
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
game_ui.hide_message()
|
||||
|
||||
|
||||
func _on_game_state_sync(state: Dictionary) -> void:
|
||||
print("Received game state sync: ", state)
|
||||
# This is for reconnection - sync local state with server
|
||||
var current_player_index = state.get("current_player_index", 0)
|
||||
var current_phase = state.get("current_phase", 0)
|
||||
var turn_number = state.get("turn_number", 1)
|
||||
|
||||
# Validate player index bounds
|
||||
if current_player_index < 0 or current_player_index > 1:
|
||||
push_error("Invalid player index in game state sync: %d" % current_player_index)
|
||||
return
|
||||
|
||||
if GameManager.game_state:
|
||||
GameManager.game_state.turn_manager.current_player_index = current_player_index
|
||||
GameManager.game_state.turn_manager.current_phase = current_phase
|
||||
GameManager.game_state.turn_manager.turn_number = turn_number
|
||||
|
||||
# Sync timer display from server state
|
||||
var timer_seconds = state.get("turn_timer_seconds", 120)
|
||||
if turn_timer_label:
|
||||
var minutes = timer_seconds / 60
|
||||
var secs = timer_seconds % 60
|
||||
turn_timer_label.text = "%d:%02d" % [minutes, secs]
|
||||
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
_update_cp_display()
|
||||
|
||||
|
||||
func _connect_attack_signal() -> void:
|
||||
# Wait for game_state to be available
|
||||
if GameManager.game_state:
|
||||
GameManager.game_state.attack_declared.connect(_on_attack_declared)
|
||||
|
||||
|
||||
func _on_attack_declared(attacker: CardInstance) -> void:
|
||||
# If human attacks and AI is the defender, AI needs to decide on blocking
|
||||
if is_vs_ai and ai_controller:
|
||||
var current_player_index = GameManager.game_state.turn_manager.current_player_index
|
||||
# AI is always player index 1, so if current player is 0 (human), AI needs to block
|
||||
if current_player_index != ai_player_index:
|
||||
# AI needs to make a block decision
|
||||
call_deferred("_process_ai_block", attacker)
|
||||
|
||||
|
||||
func _process_ai_block(attacker: CardInstance) -> void:
|
||||
if ai_controller and not ai_controller.is_processing:
|
||||
ai_controller.process_block_decision(attacker)
|
||||
|
||||
func _start_game() -> void:
|
||||
GameManager.start_new_game(player1_deck, player2_deck)
|
||||
|
||||
# Setup AI controller if playing vs AI
|
||||
if is_vs_ai:
|
||||
_setup_ai_controller()
|
||||
|
||||
# Force an update of visuals after a frame to ensure everything is ready
|
||||
call_deferred("_force_initial_update")
|
||||
|
||||
|
||||
func _setup_ai_controller() -> void:
|
||||
ai_controller = AIController.new()
|
||||
add_child(ai_controller)
|
||||
ai_controller.setup(ai_player_index, ai_difficulty, GameManager)
|
||||
ai_controller.set_game_state(GameManager.game_state)
|
||||
|
||||
# Connect AI signals
|
||||
ai_controller.ai_thinking.connect(_on_ai_thinking)
|
||||
ai_controller.ai_action_completed.connect(_on_ai_action_completed)
|
||||
|
||||
|
||||
func _on_ai_thinking(_player_index: int) -> void:
|
||||
game_ui.show_message("AI is thinking...")
|
||||
|
||||
|
||||
func _on_ai_action_completed() -> void:
|
||||
game_ui.hide_message()
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
_update_cp_display()
|
||||
|
||||
# Check if we need to continue AI processing
|
||||
if _is_ai_turn():
|
||||
call_deferred("_process_ai_turn")
|
||||
|
||||
|
||||
func _is_ai_turn() -> bool:
|
||||
if not is_vs_ai or not GameManager.game_state:
|
||||
return false
|
||||
return GameManager.game_state.turn_manager.current_player_index == ai_player_index
|
||||
|
||||
|
||||
func _process_ai_turn() -> void:
|
||||
if _is_ai_turn() and ai_controller and not ai_controller.is_processing:
|
||||
ai_controller.process_turn()
|
||||
|
||||
func _force_initial_update() -> void:
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
@@ -158,6 +553,34 @@ func _on_game_started() -> void:
|
||||
func _on_game_ended(winner_name: String) -> void:
|
||||
game_ui.show_message(winner_name + " wins the game!")
|
||||
|
||||
# For online games, report game end to server
|
||||
if is_online_game and GameManager.game_state:
|
||||
var winner_index = -1
|
||||
for i in range(2):
|
||||
var player = GameManager.game_state.get_player(i)
|
||||
if player and player.player_name == winner_name:
|
||||
winner_index = i
|
||||
break
|
||||
|
||||
if winner_index != -1:
|
||||
# Determine winner user ID based on index
|
||||
# Local player is either index 0 or 1, with opponent being the other
|
||||
var winner_user_id = ""
|
||||
if winner_index == local_player_index:
|
||||
winner_user_id = NetworkManager.current_user.get("id", "")
|
||||
# Server will determine actual winner from both clients reporting
|
||||
|
||||
var reason = "damage" # Could be "deck_out" if deck is empty
|
||||
var losing_player = GameManager.game_state.get_player(1 - winner_index)
|
||||
if losing_player and losing_player.deck.is_empty():
|
||||
reason = "deck_out"
|
||||
|
||||
NetworkManager.send_report_game_end(winner_user_id, reason)
|
||||
|
||||
# Disconnect network signals
|
||||
if is_online_game:
|
||||
_disconnect_network_signals()
|
||||
|
||||
func _on_turn_changed(_player_name: String, _turn_number: int) -> void:
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
@@ -168,12 +591,21 @@ func _on_turn_changed(_player_name: String, _turn_number: int) -> void:
|
||||
var player_index = GameManager.game_state.turn_manager.current_player_index
|
||||
table_setup.switch_camera_to_player(player_index)
|
||||
|
||||
# If AI's turn, start AI processing after a brief delay
|
||||
if _is_ai_turn():
|
||||
# Defer to allow visuals to update first
|
||||
call_deferred("_process_ai_turn")
|
||||
|
||||
func _on_phase_changed(_phase_name: String) -> void:
|
||||
_update_playable_highlights()
|
||||
_update_cp_display()
|
||||
# Refresh hand after draw phase completes (hand updates on entering main phase)
|
||||
_update_hand_display()
|
||||
|
||||
# If AI's turn and we're in a decision phase, continue AI processing
|
||||
if _is_ai_turn():
|
||||
call_deferred("_process_ai_turn")
|
||||
|
||||
func _on_damage_dealt(player_name: String, _amount: int) -> void:
|
||||
# Find player index
|
||||
if GameManager.game_state:
|
||||
@@ -226,9 +658,18 @@ func _update_playable_highlights() -> void:
|
||||
hand_display.clear_highlights()
|
||||
|
||||
func _on_hand_card_action(card: CardInstance, action: String) -> void:
|
||||
# Block input if not local player's turn in online game
|
||||
if is_online_game and not _is_local_player_turn():
|
||||
game_ui.show_message("Not your turn!")
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
game_ui.hide_message()
|
||||
return
|
||||
|
||||
match action:
|
||||
"play":
|
||||
# Try to play the card
|
||||
if is_online_game:
|
||||
NetworkManager.send_play_card(card.instance_id)
|
||||
GameManager.try_play_card(card)
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
@@ -236,6 +677,8 @@ func _on_hand_card_action(card: CardInstance, action: String) -> void:
|
||||
|
||||
"discard_cp":
|
||||
# Discard for CP
|
||||
if is_online_game:
|
||||
NetworkManager.send_discard_for_cp(card.instance_id)
|
||||
GameManager.discard_card_for_cp(card)
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
@@ -280,6 +723,11 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
|
||||
GameManager.InputMode.SELECT_CP_SOURCE:
|
||||
if zone_type == Enums.ZoneType.FIELD_BACKUPS:
|
||||
if player_index == GameManager.game_state.turn_manager.current_player_index:
|
||||
# Block if not local player's turn in online game
|
||||
if is_online_game and not _is_local_player_turn():
|
||||
return
|
||||
if is_online_game:
|
||||
NetworkManager.send_dull_backup_for_cp(card.instance_id)
|
||||
GameManager.dull_backup_for_cp(card)
|
||||
_sync_visuals()
|
||||
_update_cp_display()
|
||||
@@ -288,6 +736,11 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
|
||||
GameManager.InputMode.SELECT_ATTACKER:
|
||||
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
|
||||
if player_index == GameManager.game_state.turn_manager.current_player_index:
|
||||
# Block if not local player's turn in online game
|
||||
if is_online_game and not _is_local_player_turn():
|
||||
return
|
||||
if is_online_game:
|
||||
NetworkManager.send_attack(card.instance_id)
|
||||
GameManager.declare_attack(card)
|
||||
_sync_visuals()
|
||||
return
|
||||
@@ -296,6 +749,13 @@ func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, playe
|
||||
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
|
||||
var opponent_index = 1 - GameManager.game_state.turn_manager.current_player_index
|
||||
if player_index == opponent_index:
|
||||
# In online games, only the defending player (non-active) can block
|
||||
# Check if we are the defender
|
||||
if is_online_game and local_player_index == GameManager.game_state.turn_manager.current_player_index:
|
||||
# We are the attacker, can't block
|
||||
return
|
||||
if is_online_game:
|
||||
NetworkManager.send_block(card.instance_id)
|
||||
GameManager.declare_block(card)
|
||||
_sync_visuals()
|
||||
return
|
||||
@@ -310,21 +770,31 @@ func _connect_field_card_signals() -> void:
|
||||
game_ui.field_card_action_requested.connect(_on_field_card_action)
|
||||
|
||||
func _on_field_card_action(card: CardInstance, zone_type: Enums.ZoneType, player_index: int, action: String) -> void:
|
||||
# Block input if not local player's turn in online game
|
||||
if is_online_game and not _is_local_player_turn():
|
||||
game_ui.show_message("Not your turn!")
|
||||
return
|
||||
|
||||
match action:
|
||||
"dull_cp":
|
||||
if is_online_game:
|
||||
NetworkManager.send_dull_backup_for_cp(card.instance_id)
|
||||
GameManager.dull_backup_for_cp(card)
|
||||
_sync_visuals()
|
||||
_update_cp_display()
|
||||
"attack":
|
||||
if is_online_game:
|
||||
NetworkManager.send_attack(card.instance_id)
|
||||
GameManager.declare_attack(card)
|
||||
_sync_visuals()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# Keyboard shortcuts
|
||||
if event is InputEventKey and event.pressed:
|
||||
# Ctrl+Z for undo
|
||||
# Ctrl+Z for undo (disabled in online games)
|
||||
if event.keycode == KEY_Z and event.ctrl_pressed:
|
||||
_on_undo_requested()
|
||||
if not is_online_game:
|
||||
_on_undo_requested()
|
||||
return
|
||||
|
||||
# L key to toggle action log
|
||||
@@ -336,6 +806,10 @@ func _input(event: InputEvent) -> void:
|
||||
match event.keycode:
|
||||
KEY_SPACE:
|
||||
# Pass priority / end phase
|
||||
if is_online_game:
|
||||
if not _is_local_player_turn():
|
||||
return
|
||||
NetworkManager.send_pass()
|
||||
GameManager.pass_priority()
|
||||
_sync_visuals()
|
||||
_update_hand_display()
|
||||
|
||||
@@ -92,6 +92,7 @@ func _parse_card(data: Dictionary) -> CardData:
|
||||
card.category = data.get("category", "") if data.get("category") != null else ""
|
||||
card.is_generic = data.get("is_generic", false) if data.get("is_generic") != null else false
|
||||
card.has_ex_burst = data.get("has_ex_burst", false) if data.get("has_ex_burst") != null else false
|
||||
card.has_haste = data.get("has_haste", false) if data.get("has_haste") != null else false
|
||||
card.image_path = data.get("image", "") if data.get("image") != null else ""
|
||||
|
||||
# Parse abilities
|
||||
@@ -455,6 +456,7 @@ class CardData:
|
||||
var category: String = ""
|
||||
var is_generic: bool = false
|
||||
var has_ex_burst: bool = false
|
||||
var has_haste: bool = false
|
||||
var image_path: String = ""
|
||||
var abilities: Array[AbilityData] = []
|
||||
|
||||
@@ -466,6 +468,14 @@ class CardData:
|
||||
func is_multi_element() -> bool:
|
||||
return elements.size() > 1
|
||||
|
||||
## Check if card has a specific ability by name (e.g., "Brave", "Haste", "First Strike")
|
||||
func has_ability(ability_name: String) -> bool:
|
||||
for ability in abilities:
|
||||
if ability.name.to_lower() == ability_name.to_lower():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
class AbilityData:
|
||||
var type: Enums.AbilityType = Enums.AbilityType.FIELD
|
||||
var name: String = ""
|
||||
|
||||
@@ -25,6 +25,17 @@ var zone_type: Enums.ZoneType = Enums.ZoneType.DECK
|
||||
# Temporary effects (cleared at end of turn)
|
||||
var power_modifiers: Array[int] = []
|
||||
var temporary_abilities: Array = []
|
||||
var temporary_keywords: Dictionary = {} # keyword -> duration
|
||||
var restrictions: Dictionary = {} # restriction_type -> duration
|
||||
var requirements: Dictionary = {} # requirement_type -> duration
|
||||
var protections: Dictionary = {} # protection_type -> duration
|
||||
|
||||
# Counters
|
||||
var counters: Dictionary = {} # counter_type -> count
|
||||
|
||||
# Special states
|
||||
var is_frozen: bool = false
|
||||
var base_power_override: int = -1 # -1 means use card_data.power
|
||||
|
||||
# Turn tracking
|
||||
var turns_on_field: int = 0
|
||||
@@ -43,11 +54,18 @@ func _init(data: CardDatabase.CardData = null, owner: int = 0) -> void:
|
||||
if data:
|
||||
current_power = data.power
|
||||
|
||||
## Get the card's current power (base + modifiers)
|
||||
## Get the card's current power (base + modifiers + field effects)
|
||||
func get_power() -> int:
|
||||
var total = current_power
|
||||
for mod in power_modifiers:
|
||||
total += mod
|
||||
|
||||
# Add field effect modifiers from AbilitySystem
|
||||
var tree = Engine.get_main_loop()
|
||||
if tree and tree.root and tree.root.has_node("AbilitySystem"):
|
||||
var ability_system = tree.root.get_node("AbilitySystem")
|
||||
total += ability_system.get_field_power_modifier(self)
|
||||
|
||||
return max(0, total)
|
||||
|
||||
## Check if this is a Forward
|
||||
@@ -76,8 +94,18 @@ func dull() -> void:
|
||||
|
||||
## Activate this card
|
||||
func activate() -> void:
|
||||
# Frozen cards can't activate during Active Phase (but can be activated by effects)
|
||||
state = Enums.CardState.ACTIVE
|
||||
|
||||
|
||||
## Attempt to activate during Active Phase (respects frozen)
|
||||
func activate_during_active_phase() -> bool:
|
||||
if is_frozen:
|
||||
is_frozen = false # Frozen wears off but card stays dull
|
||||
return false
|
||||
state = Enums.CardState.ACTIVE
|
||||
return true
|
||||
|
||||
## Check if this card can attack
|
||||
func can_attack() -> bool:
|
||||
if not is_forward():
|
||||
@@ -86,19 +114,34 @@ func can_attack() -> bool:
|
||||
return false
|
||||
if attacked_this_turn:
|
||||
return false
|
||||
if has_restriction("CANT_ATTACK"):
|
||||
return false
|
||||
# Must have been on field since start of turn (or have Haste)
|
||||
if turns_on_field < 1 and not has_haste():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Check if this card must attack (if able)
|
||||
func must_attack() -> bool:
|
||||
return has_requirement("MUST_ATTACK")
|
||||
|
||||
|
||||
## Check if this card can block
|
||||
func can_block() -> bool:
|
||||
if not is_forward():
|
||||
return false
|
||||
if is_dull():
|
||||
return false
|
||||
if has_restriction("CANT_BLOCK"):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Check if this card must block (if able)
|
||||
func must_block() -> bool:
|
||||
return has_requirement("MUST_BLOCK")
|
||||
|
||||
## Check if this card can use dull abilities
|
||||
func can_use_dull_ability() -> bool:
|
||||
# Must have been on field since start of turn (or have Haste)
|
||||
@@ -109,14 +152,21 @@ func can_use_dull_ability() -> bool:
|
||||
return false
|
||||
return true
|
||||
|
||||
## Check if card has Haste (from abilities)
|
||||
## Check if card has Haste
|
||||
func has_haste() -> bool:
|
||||
if not card_data:
|
||||
return false
|
||||
# Check explicit has_haste field first
|
||||
if card_data.has_haste:
|
||||
return true
|
||||
# Fallback: search ability text for backwards compatibility
|
||||
for ability in card_data.abilities:
|
||||
if ability.type == Enums.AbilityType.FIELD:
|
||||
if "haste" in ability.effect.to_lower():
|
||||
return true
|
||||
# Check field-granted keywords
|
||||
if _has_field_keyword("HASTE"):
|
||||
return true
|
||||
return false
|
||||
|
||||
## Check if card has Brave (from abilities)
|
||||
@@ -127,6 +177,9 @@ func has_brave() -> bool:
|
||||
if ability.type == Enums.AbilityType.FIELD:
|
||||
if "brave" in ability.effect.to_lower():
|
||||
return true
|
||||
# Check field-granted keywords
|
||||
if _has_field_keyword("BRAVE"):
|
||||
return true
|
||||
return false
|
||||
|
||||
## Check if card has First Strike
|
||||
@@ -137,6 +190,17 @@ func has_first_strike() -> bool:
|
||||
if ability.type == Enums.AbilityType.FIELD:
|
||||
if "first strike" in ability.effect.to_lower():
|
||||
return true
|
||||
# Check field-granted keywords
|
||||
if _has_field_keyword("FIRST_STRIKE"):
|
||||
return true
|
||||
return false
|
||||
|
||||
## Check for field-granted keyword from AbilitySystem
|
||||
func _has_field_keyword(keyword: String) -> bool:
|
||||
var tree = Engine.get_main_loop()
|
||||
if tree and tree.root and tree.root.has_node("AbilitySystem"):
|
||||
var ability_system = tree.root.get_node("AbilitySystem")
|
||||
return ability_system.has_field_keyword(self, keyword)
|
||||
return false
|
||||
|
||||
## Get primary element
|
||||
@@ -191,3 +255,288 @@ func get_display_name() -> String:
|
||||
|
||||
func _to_string() -> String:
|
||||
return "[CardInstance: %s (%s)]" % [get_display_name(), instance_id]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# KEYWORD MANAGEMENT
|
||||
# =============================================================================
|
||||
|
||||
## Add a temporary keyword
|
||||
func add_temporary_keyword(keyword: String, duration: String = "END_OF_TURN") -> void:
|
||||
temporary_keywords[keyword.to_upper()] = duration
|
||||
|
||||
|
||||
## Check if card has a keyword (from card data, temp, or field effects)
|
||||
func has_keyword(keyword: String) -> bool:
|
||||
var kw_upper = keyword.to_upper()
|
||||
|
||||
# Check temporary keywords
|
||||
if temporary_keywords.has(kw_upper):
|
||||
return true
|
||||
|
||||
# Check field-granted keywords
|
||||
if _has_field_keyword(kw_upper):
|
||||
return true
|
||||
|
||||
# Check card's base keywords
|
||||
if card_data:
|
||||
match kw_upper:
|
||||
"HASTE":
|
||||
return card_data.has_haste
|
||||
"BRAVE":
|
||||
for ability in card_data.abilities:
|
||||
if ability.type == Enums.AbilityType.FIELD and "brave" in ability.effect.to_lower():
|
||||
return true
|
||||
"FIRST_STRIKE":
|
||||
for ability in card_data.abilities:
|
||||
if ability.type == Enums.AbilityType.FIELD and "first strike" in ability.effect.to_lower():
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Remove all abilities
|
||||
func remove_all_abilities() -> void:
|
||||
temporary_abilities.clear()
|
||||
temporary_keywords.clear()
|
||||
|
||||
|
||||
## Remove a specific ability
|
||||
func remove_ability(ability_name: String) -> void:
|
||||
var name_upper = ability_name.to_upper()
|
||||
temporary_abilities.erase(name_upper)
|
||||
temporary_keywords.erase(name_upper)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RESTRICTIONS & REQUIREMENTS
|
||||
# =============================================================================
|
||||
|
||||
## Add a restriction (can't attack, can't block, etc.)
|
||||
func add_restriction(restriction_type: String, duration: String = "END_OF_TURN") -> void:
|
||||
restrictions[restriction_type.to_upper()] = duration
|
||||
|
||||
|
||||
## Check if card has a restriction
|
||||
func has_restriction(restriction_type: String) -> bool:
|
||||
return restrictions.has(restriction_type.to_upper())
|
||||
|
||||
|
||||
## Add a requirement (must attack, must block, etc.)
|
||||
func add_requirement(requirement_type: String, duration: String = "END_OF_TURN") -> void:
|
||||
requirements[requirement_type.to_upper()] = duration
|
||||
|
||||
|
||||
## Check if card has a requirement
|
||||
func has_requirement(requirement_type: String) -> bool:
|
||||
return requirements.has(requirement_type.to_upper())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROTECTION
|
||||
# =============================================================================
|
||||
|
||||
## Add protection from damage/effects
|
||||
func add_protection(protection_type: String, duration: String = "END_OF_TURN") -> void:
|
||||
protections[protection_type.to_upper()] = duration
|
||||
|
||||
|
||||
## Check if card has protection from something
|
||||
func has_protection(protection_type: String) -> bool:
|
||||
var pt_upper = protection_type.to_upper()
|
||||
|
||||
# Check local protections
|
||||
if protections.has(pt_upper):
|
||||
return true
|
||||
|
||||
# Check for ALL protection
|
||||
if protections.has("ALL"):
|
||||
return true
|
||||
|
||||
# Check field-granted protection
|
||||
var tree = Engine.get_main_loop()
|
||||
if tree and tree.root and tree.root.has_node("AbilitySystem"):
|
||||
var ability_system = tree.root.get_node("AbilitySystem")
|
||||
return ability_system.has_field_protection(self, protection_type)
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FROZEN STATE
|
||||
# =============================================================================
|
||||
|
||||
## Set frozen state
|
||||
func set_frozen(frozen: bool) -> void:
|
||||
is_frozen = frozen
|
||||
|
||||
|
||||
## Check if frozen (doesn't activate during Active Phase)
|
||||
func is_card_frozen() -> bool:
|
||||
return is_frozen
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COUNTERS
|
||||
# =============================================================================
|
||||
|
||||
## Add counters
|
||||
func add_counters(counter_type: String, amount: int = 1) -> void:
|
||||
var ct = counter_type.to_upper()
|
||||
counters[ct] = counters.get(ct, 0) + amount
|
||||
|
||||
|
||||
## Remove counters
|
||||
func remove_counters(counter_type: String, amount: int = 1) -> void:
|
||||
var ct = counter_type.to_upper()
|
||||
counters[ct] = max(0, counters.get(ct, 0) - amount)
|
||||
if counters[ct] == 0:
|
||||
counters.erase(ct)
|
||||
|
||||
|
||||
## Get counter count
|
||||
func get_counter_count(counter_type: String) -> int:
|
||||
return counters.get(counter_type.to_upper(), 0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POWER MANIPULATION
|
||||
# =============================================================================
|
||||
|
||||
## Set base power (for swap/transform effects)
|
||||
func set_base_power(new_power: int) -> void:
|
||||
base_power_override = new_power
|
||||
|
||||
|
||||
## Get base power (respecting override)
|
||||
func get_base_power() -> int:
|
||||
if base_power_override >= 0:
|
||||
return base_power_override
|
||||
return card_data.power if card_data else 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DAMAGE MANIPULATION
|
||||
# =============================================================================
|
||||
|
||||
## Heal damage
|
||||
func heal_damage(amount: int) -> void:
|
||||
damage_received = max(0, damage_received - amount)
|
||||
|
||||
|
||||
## Remove all damage
|
||||
func remove_all_damage() -> void:
|
||||
damage_received = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COPY & TRANSFORM
|
||||
# =============================================================================
|
||||
|
||||
## Copy abilities from another card
|
||||
func copy_abilities_from(other: CardInstance) -> void:
|
||||
if other and other.card_data:
|
||||
for ability in other.card_data.abilities:
|
||||
temporary_abilities.append(ability)
|
||||
|
||||
|
||||
## Copy stats from another card
|
||||
func copy_stats_from(other: CardInstance) -> void:
|
||||
if other:
|
||||
base_power_override = other.get_base_power()
|
||||
|
||||
|
||||
## Become a copy of another card
|
||||
func become_copy_of(other: CardInstance) -> void:
|
||||
if other:
|
||||
copy_stats_from(other)
|
||||
copy_abilities_from(other)
|
||||
|
||||
|
||||
## Transform into something else
|
||||
func transform(into: Dictionary) -> void:
|
||||
if into.has("power"):
|
||||
base_power_override = int(into.power)
|
||||
if into.has("name"):
|
||||
# Transform name handling would require additional infrastructure
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROPERTY MODIFICATION
|
||||
# =============================================================================
|
||||
|
||||
# Temporary element/job storage
|
||||
var _temp_element: String = ""
|
||||
var _temp_element_duration: String = ""
|
||||
var _temp_job: String = ""
|
||||
var _temp_job_duration: String = ""
|
||||
|
||||
|
||||
## Set temporary element
|
||||
func set_temporary_element(element: String, duration: String = "END_OF_TURN") -> void:
|
||||
_temp_element = element.to_upper()
|
||||
_temp_element_duration = duration
|
||||
|
||||
|
||||
## Set temporary job
|
||||
func set_temporary_job(job: String, duration: String = "END_OF_TURN") -> void:
|
||||
_temp_job = job
|
||||
_temp_job_duration = duration
|
||||
|
||||
|
||||
## Get current elements (including temporary)
|
||||
func get_current_elements() -> Array:
|
||||
if _temp_element != "":
|
||||
var element = Enums.element_from_string(_temp_element)
|
||||
return [element]
|
||||
return get_elements()
|
||||
|
||||
|
||||
## Get current job (including temporary)
|
||||
func get_current_job() -> String:
|
||||
if _temp_job != "":
|
||||
return _temp_job
|
||||
return card_data.job if card_data else ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLEANUP
|
||||
# =============================================================================
|
||||
|
||||
## Reset temporary effects at end of turn (extended)
|
||||
func end_turn_cleanup() -> void:
|
||||
power_modifiers.clear()
|
||||
temporary_abilities.clear()
|
||||
damage_received = 0
|
||||
attacked_this_turn = false
|
||||
|
||||
# Clear END_OF_TURN duration effects
|
||||
_clear_duration_effects("END_OF_TURN")
|
||||
|
||||
|
||||
## Clear effects with specific duration
|
||||
func _clear_duration_effects(duration: String) -> void:
|
||||
for key in temporary_keywords.keys():
|
||||
if temporary_keywords[key] == duration:
|
||||
temporary_keywords.erase(key)
|
||||
|
||||
for key in restrictions.keys():
|
||||
if restrictions[key] == duration:
|
||||
restrictions.erase(key)
|
||||
|
||||
for key in requirements.keys():
|
||||
if requirements[key] == duration:
|
||||
requirements.erase(key)
|
||||
|
||||
for key in protections.keys():
|
||||
if protections[key] == duration:
|
||||
protections.erase(key)
|
||||
|
||||
# Clear temporary element/job
|
||||
if _temp_element_duration == duration:
|
||||
_temp_element = ""
|
||||
_temp_element_duration = ""
|
||||
if _temp_job_duration == duration:
|
||||
_temp_job = ""
|
||||
_temp_job_duration = ""
|
||||
|
||||
@@ -41,6 +41,7 @@ enum TurnPhase {
|
||||
|
||||
## Attack Phase Steps
|
||||
enum AttackStep {
|
||||
NONE, # Not in attack phase or between attacks
|
||||
PREPARATION,
|
||||
DECLARATION,
|
||||
BLOCK_DECLARATION,
|
||||
|
||||
@@ -63,6 +63,17 @@ func start_game(first_player: int = -1) -> void:
|
||||
players[1].draw_cards(5)
|
||||
|
||||
game_active = true
|
||||
|
||||
# Connect ability system if available
|
||||
var ability_system = Engine.get_singleton("AbilitySystem")
|
||||
if ability_system == null:
|
||||
# Try getting from scene tree (autoload)
|
||||
var tree = Engine.get_main_loop()
|
||||
if tree and tree.root.has_node("AbilitySystem"):
|
||||
ability_system = tree.root.get_node("AbilitySystem")
|
||||
if ability_system:
|
||||
ability_system.connect_to_game(self)
|
||||
|
||||
turn_manager.start_game(first_player)
|
||||
|
||||
game_started.emit()
|
||||
|
||||
798
scripts/game/abilities/AbilitySystem.gd
Normal file
798
scripts/game/abilities/AbilitySystem.gd
Normal file
@@ -0,0 +1,798 @@
|
||||
class_name AbilitySystem
|
||||
extends Node
|
||||
|
||||
## AbilitySystem - Central coordinator for ability processing
|
||||
## Loads processed abilities and handles trigger matching and effect resolution
|
||||
|
||||
signal ability_triggered(source: CardInstance, ability: Dictionary)
|
||||
signal effect_resolved(effect: Dictionary, targets: Array)
|
||||
signal targeting_required(effect: Dictionary, valid_targets: Array, callback: Callable)
|
||||
signal targeting_completed(effect: Dictionary, selected_targets: Array)
|
||||
signal choice_modal_required(effect: Dictionary, modes: Array, callback: Callable)
|
||||
signal optional_effect_prompt(player_index: int, effect: Dictionary, description: String, callback: Callable)
|
||||
|
||||
const ABILITIES_PATH = "res://data/abilities_processed.json"
|
||||
|
||||
# Loaded ability data
|
||||
var _abilities: Dictionary = {} # card_id -> Array of parsed abilities
|
||||
var _version: String = ""
|
||||
var _stats: Dictionary = {}
|
||||
|
||||
# Sub-systems
|
||||
var trigger_matcher: TriggerMatcher
|
||||
var effect_resolver: EffectResolver
|
||||
var target_selector: TargetSelector
|
||||
var field_effect_manager: FieldEffectManager
|
||||
var condition_checker: ConditionChecker
|
||||
|
||||
# UI Reference
|
||||
var choice_modal: ChoiceModal = null
|
||||
|
||||
# Effect resolution stack
|
||||
var _pending_effects: Array = []
|
||||
var _is_resolving: bool = false
|
||||
var _waiting_for_choice: bool = false
|
||||
var _waiting_for_optional: bool = false
|
||||
|
||||
# Connected game state
|
||||
var _game_state = null # GameState reference
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_abilities()
|
||||
_init_subsystems()
|
||||
|
||||
|
||||
func _init_subsystems() -> void:
|
||||
trigger_matcher = TriggerMatcher.new()
|
||||
effect_resolver = EffectResolver.new()
|
||||
target_selector = TargetSelector.new()
|
||||
field_effect_manager = FieldEffectManager.new()
|
||||
condition_checker = ConditionChecker.new()
|
||||
|
||||
# Wire ConditionChecker to subsystems that need it
|
||||
effect_resolver.condition_checker = condition_checker
|
||||
trigger_matcher.condition_checker = condition_checker
|
||||
|
||||
# Connect effect resolver signals
|
||||
effect_resolver.effect_completed.connect(_on_effect_completed)
|
||||
effect_resolver.choice_required.connect(_on_choice_required)
|
||||
|
||||
|
||||
func _load_abilities() -> void:
|
||||
if not FileAccess.file_exists(ABILITIES_PATH):
|
||||
push_warning("AbilitySystem: No processed abilities found at " + ABILITIES_PATH)
|
||||
push_warning("Run: python tools/ability_processor.py")
|
||||
return
|
||||
|
||||
var file = FileAccess.open(ABILITIES_PATH, FileAccess.READ)
|
||||
if not file:
|
||||
push_error("AbilitySystem: Failed to open " + ABILITIES_PATH)
|
||||
return
|
||||
|
||||
var json = JSON.new()
|
||||
var error = json.parse(file.get_as_text())
|
||||
file.close()
|
||||
|
||||
if error != OK:
|
||||
push_error("AbilitySystem: Failed to parse abilities JSON: " + json.get_error_message())
|
||||
return
|
||||
|
||||
var data = json.get_data()
|
||||
_version = data.get("version", "unknown")
|
||||
_stats = data.get("statistics", {})
|
||||
_abilities = data.get("abilities", {})
|
||||
|
||||
print("AbilitySystem: Loaded v%s - %d cards, %d abilities (%d high confidence)" % [
|
||||
_version,
|
||||
_stats.get("total_cards", 0),
|
||||
_stats.get("total_abilities", 0),
|
||||
_stats.get("parsed_high", 0)
|
||||
])
|
||||
|
||||
|
||||
## Connect to a game state to listen for events
|
||||
func connect_to_game(game_state) -> void:
|
||||
if _game_state:
|
||||
_disconnect_from_game()
|
||||
|
||||
_game_state = game_state
|
||||
|
||||
# Connect to game events that can trigger abilities
|
||||
game_state.card_played.connect(_on_card_played)
|
||||
game_state.summon_cast.connect(_on_summon_cast)
|
||||
game_state.attack_declared.connect(_on_attack_declared)
|
||||
game_state.block_declared.connect(_on_block_declared)
|
||||
game_state.forward_broken.connect(_on_forward_broken)
|
||||
game_state.damage_dealt.connect(_on_damage_dealt)
|
||||
game_state.card_moved.connect(_on_card_moved)
|
||||
game_state.combat_resolved.connect(_on_combat_resolved)
|
||||
|
||||
# Turn manager signals
|
||||
if game_state.turn_manager:
|
||||
game_state.turn_manager.phase_changed.connect(_on_phase_changed)
|
||||
game_state.turn_manager.turn_started.connect(_on_turn_started)
|
||||
game_state.turn_manager.turn_ended.connect(_on_turn_ended)
|
||||
|
||||
print("AbilitySystem: Connected to GameState")
|
||||
|
||||
|
||||
func _disconnect_from_game() -> void:
|
||||
if not _game_state:
|
||||
return
|
||||
|
||||
# Disconnect all signals
|
||||
if _game_state.card_played.is_connected(_on_card_played):
|
||||
_game_state.card_played.disconnect(_on_card_played)
|
||||
if _game_state.summon_cast.is_connected(_on_summon_cast):
|
||||
_game_state.summon_cast.disconnect(_on_summon_cast)
|
||||
if _game_state.attack_declared.is_connected(_on_attack_declared):
|
||||
_game_state.attack_declared.disconnect(_on_attack_declared)
|
||||
if _game_state.block_declared.is_connected(_on_block_declared):
|
||||
_game_state.block_declared.disconnect(_on_block_declared)
|
||||
if _game_state.forward_broken.is_connected(_on_forward_broken):
|
||||
_game_state.forward_broken.disconnect(_on_forward_broken)
|
||||
if _game_state.damage_dealt.is_connected(_on_damage_dealt):
|
||||
_game_state.damage_dealt.disconnect(_on_damage_dealt)
|
||||
if _game_state.card_moved.is_connected(_on_card_moved):
|
||||
_game_state.card_moved.disconnect(_on_card_moved)
|
||||
|
||||
_game_state = null
|
||||
|
||||
|
||||
## Get parsed abilities for a card
|
||||
func get_abilities(card_id: String) -> Array:
|
||||
return _abilities.get(card_id, [])
|
||||
|
||||
|
||||
## Check if a card has parsed abilities
|
||||
func has_abilities(card_id: String) -> bool:
|
||||
return _abilities.has(card_id) and _abilities[card_id].size() > 0
|
||||
|
||||
|
||||
## Get a specific parsed ability
|
||||
func get_ability(card_id: String, ability_index: int) -> Dictionary:
|
||||
var abilities = get_abilities(card_id)
|
||||
if ability_index >= 0 and ability_index < abilities.size():
|
||||
return abilities[ability_index]
|
||||
return {}
|
||||
|
||||
|
||||
## Process a game event and trigger matching abilities
|
||||
func process_event(event_type: String, event_data: Dictionary) -> void:
|
||||
if not _game_state:
|
||||
return
|
||||
|
||||
var triggered = trigger_matcher.find_triggered_abilities(
|
||||
event_type, event_data, _game_state, _abilities
|
||||
)
|
||||
|
||||
for trigger_info in triggered:
|
||||
_queue_ability(trigger_info)
|
||||
|
||||
|
||||
## Queue an ability for resolution
|
||||
func _queue_ability(trigger_info: Dictionary) -> void:
|
||||
var source = trigger_info.source as CardInstance
|
||||
var ability = trigger_info.ability as Dictionary
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
if not parsed or not parsed.has("effects"):
|
||||
return
|
||||
|
||||
# Check if ability has a cost that needs to be paid
|
||||
var cost = parsed.get("cost", {})
|
||||
if not cost.is_empty() and source and _game_state:
|
||||
var player = _game_state.get_player(source.controller_index)
|
||||
if player:
|
||||
# Validate cost
|
||||
var validation = _validate_ability_cost(cost, source, player)
|
||||
if not validation.valid:
|
||||
# Cannot pay cost - emit signal and skip ability
|
||||
ability_cost_failed.emit(source, ability, validation.reason)
|
||||
push_warning("AbilitySystem: Cannot pay cost for ability - %s" % validation.reason)
|
||||
return
|
||||
|
||||
# Pay the cost
|
||||
_pay_ability_cost(cost, source, player)
|
||||
|
||||
ability_triggered.emit(source, ability)
|
||||
|
||||
# Add effects to pending stack (LIFO for proper resolution order)
|
||||
var effects = parsed.get("effects", [])
|
||||
for i in range(effects.size() - 1, -1, -1):
|
||||
_pending_effects.push_front({
|
||||
"effect": effects[i],
|
||||
"source": source,
|
||||
"controller": source.controller_index,
|
||||
"ability": ability,
|
||||
"event_data": trigger_info.get("event_data", {})
|
||||
})
|
||||
|
||||
# Start resolving if not already
|
||||
if not _is_resolving:
|
||||
_resolve_next_effect()
|
||||
|
||||
|
||||
## Resolve the next pending effect
|
||||
func _resolve_next_effect() -> void:
|
||||
if _pending_effects.is_empty():
|
||||
_is_resolving = false
|
||||
return
|
||||
|
||||
_is_resolving = true
|
||||
var pending = _pending_effects[0]
|
||||
var effect = pending.effect
|
||||
var source = pending.source
|
||||
|
||||
# Check if effect is optional and we haven't prompted yet
|
||||
if effect.get("optional", false) and not pending.get("optional_prompted", false):
|
||||
_waiting_for_optional = true
|
||||
pending["optional_prompted"] = true # Mark as prompted to avoid re-prompting
|
||||
|
||||
# Determine which player should decide
|
||||
var player_index = source.controller_index if source else 0
|
||||
|
||||
# Build description from effect
|
||||
var description = _build_effect_description(effect)
|
||||
|
||||
# Emit signal for UI to handle
|
||||
optional_effect_prompt.emit(player_index, effect, description, _on_optional_effect_choice)
|
||||
return # Wait for callback
|
||||
|
||||
# Check if effect needs targeting
|
||||
if _effect_needs_targeting(effect):
|
||||
var valid_targets = target_selector.get_valid_targets(
|
||||
effect.get("target", {}), source, _game_state
|
||||
)
|
||||
|
||||
if valid_targets.is_empty():
|
||||
# No valid targets, skip effect
|
||||
_pending_effects.pop_front()
|
||||
_resolve_next_effect()
|
||||
return
|
||||
|
||||
# Request target selection from player
|
||||
targeting_required.emit(effect, valid_targets, _on_targets_selected)
|
||||
# Wait for targeting_completed signal
|
||||
else:
|
||||
# Resolve immediately
|
||||
_execute_effect(pending)
|
||||
|
||||
|
||||
## Execute an effect with its targets
|
||||
func _execute_effect(pending: Dictionary) -> void:
|
||||
var effect = pending.effect
|
||||
var source = pending.source
|
||||
var targets = pending.get("targets", [])
|
||||
|
||||
effect_resolver.resolve(effect, source, targets, _game_state)
|
||||
|
||||
|
||||
## Called when effect resolution completes
|
||||
func _on_effect_completed(effect: Dictionary, targets: Array) -> void:
|
||||
effect_resolved.emit(effect, targets)
|
||||
|
||||
if not _pending_effects.is_empty():
|
||||
_pending_effects.pop_front()
|
||||
|
||||
_resolve_next_effect()
|
||||
|
||||
|
||||
## Called when player selects targets
|
||||
func _on_targets_selected(targets: Array) -> void:
|
||||
if _pending_effects.is_empty():
|
||||
return
|
||||
|
||||
_pending_effects[0]["targets"] = targets
|
||||
targeting_completed.emit(_pending_effects[0].effect, targets)
|
||||
_execute_effect(_pending_effects[0])
|
||||
|
||||
|
||||
## Check if effect requires player targeting
|
||||
func _effect_needs_targeting(effect: Dictionary) -> bool:
|
||||
if not effect.has("target"):
|
||||
return false
|
||||
var target = effect.target
|
||||
return target.get("type") == "CHOOSE"
|
||||
|
||||
|
||||
## Called when player responds to optional effect prompt
|
||||
func _on_optional_effect_choice(accepted: bool) -> void:
|
||||
_waiting_for_optional = false
|
||||
|
||||
if _pending_effects.is_empty():
|
||||
return
|
||||
|
||||
if accepted:
|
||||
# Player chose to execute the optional effect
|
||||
# Continue with normal resolution (targeting or execution)
|
||||
var pending = _pending_effects[0]
|
||||
var effect = pending.effect
|
||||
|
||||
if _effect_needs_targeting(effect):
|
||||
var source = pending.source
|
||||
var valid_targets = target_selector.get_valid_targets(
|
||||
effect.get("target", {}), source, _game_state
|
||||
)
|
||||
|
||||
if valid_targets.is_empty():
|
||||
_pending_effects.pop_front()
|
||||
_resolve_next_effect()
|
||||
return
|
||||
|
||||
targeting_required.emit(effect, valid_targets, _on_targets_selected)
|
||||
else:
|
||||
_execute_effect(pending)
|
||||
else:
|
||||
# Player declined the optional effect - skip it
|
||||
_pending_effects.pop_front()
|
||||
_resolve_next_effect()
|
||||
|
||||
|
||||
## Build a human-readable description of an effect for prompts
|
||||
func _build_effect_description(effect: Dictionary) -> String:
|
||||
var effect_type = str(effect.get("type", "")).to_upper()
|
||||
var amount = effect.get("amount", 0)
|
||||
|
||||
match effect_type:
|
||||
"DRAW":
|
||||
var count = effect.get("amount", 1)
|
||||
return "Draw %d card%s" % [count, "s" if count > 1 else ""]
|
||||
"DAMAGE":
|
||||
return "Deal %d damage" % amount
|
||||
"POWER_MOD":
|
||||
var sign = "+" if amount >= 0 else ""
|
||||
return "Give %s%d power" % [sign, amount]
|
||||
"DULL":
|
||||
return "Dull a Forward"
|
||||
"ACTIVATE":
|
||||
return "Activate a card"
|
||||
"BREAK":
|
||||
return "Break a card"
|
||||
"RETURN":
|
||||
return "Return a card to hand"
|
||||
"SEARCH":
|
||||
return "Search your deck"
|
||||
"DISCARD":
|
||||
var count = effect.get("amount", 1)
|
||||
return "Discard %d card%s" % [count, "s" if count > 1 else ""]
|
||||
_:
|
||||
# Use the original_text if available
|
||||
if effect.has("original_text"):
|
||||
return effect.original_text
|
||||
return "Use this effect"
|
||||
|
||||
|
||||
## Called when EffectResolver encounters a CHOOSE_MODE effect
|
||||
func _on_choice_required(effect: Dictionary, modes: Array) -> void:
|
||||
if _pending_effects.is_empty():
|
||||
return
|
||||
|
||||
var pending = _pending_effects[0]
|
||||
var source = pending.get("source") as CardInstance
|
||||
|
||||
# Check for enhanced condition (e.g., "If you have 5+ Ifrit, select 3 instead")
|
||||
var select_count = effect.get("select_count", 1)
|
||||
var select_up_to = effect.get("select_up_to", false)
|
||||
|
||||
var enhanced = effect.get("enhanced_condition", {})
|
||||
if not enhanced.is_empty() and _check_enhanced_condition(enhanced, source):
|
||||
select_count = enhanced.get("select_count", select_count)
|
||||
select_up_to = enhanced.get("select_up_to", select_up_to)
|
||||
|
||||
# If we have a ChoiceModal, use it
|
||||
if choice_modal:
|
||||
_waiting_for_choice = true
|
||||
_handle_modal_choice_async(effect, modes, select_count, select_up_to, source)
|
||||
else:
|
||||
# No UI available - auto-select first N modes
|
||||
push_warning("AbilitySystem: No ChoiceModal available, auto-selecting first mode(s)")
|
||||
var auto_selected: Array = []
|
||||
for i in range(min(select_count, modes.size())):
|
||||
auto_selected.append(i)
|
||||
_on_modes_selected(effect, modes, auto_selected, source)
|
||||
|
||||
|
||||
## Handle modal choice asynchronously
|
||||
func _handle_modal_choice_async(
|
||||
effect: Dictionary,
|
||||
modes: Array,
|
||||
select_count: int,
|
||||
select_up_to: bool,
|
||||
source: CardInstance
|
||||
) -> void:
|
||||
var selected = await choice_modal.show_choices(
|
||||
"", # Title is generated by ChoiceModal
|
||||
modes,
|
||||
select_count,
|
||||
select_up_to,
|
||||
false # Not cancellable for mandatory abilities
|
||||
)
|
||||
|
||||
_waiting_for_choice = false
|
||||
_on_modes_selected(effect, modes, selected, source)
|
||||
|
||||
|
||||
## Cached regex for enhanced condition parsing
|
||||
var _enhanced_count_regex: RegEx = null
|
||||
|
||||
|
||||
## Check if enhanced condition is met
|
||||
func _check_enhanced_condition(condition: Dictionary, source: CardInstance) -> bool:
|
||||
var description = condition.get("description", "").to_lower()
|
||||
|
||||
# Parse "if you have X or more [Card Name] in your Break Zone"
|
||||
if "break zone" in description:
|
||||
# Initialize regex once (lazy)
|
||||
if _enhanced_count_regex == null:
|
||||
_enhanced_count_regex = RegEx.new()
|
||||
_enhanced_count_regex.compile("(\\d+) or more")
|
||||
|
||||
var match_result = _enhanced_count_regex.search(description)
|
||||
if match_result:
|
||||
var required_count = int(match_result.get_string(1))
|
||||
|
||||
# Check break zone for matching cards
|
||||
if _game_state and source:
|
||||
var player = _game_state.get_player(source.controller_index)
|
||||
if player:
|
||||
# Count matching cards in break zone
|
||||
var break_zone_count = 0
|
||||
for card in player.break_zone.get_cards():
|
||||
# Simple name matching (description contains card name pattern)
|
||||
if card.card_data and card.card_data.name.to_lower() in description:
|
||||
break_zone_count += 1
|
||||
|
||||
return break_zone_count >= required_count
|
||||
|
||||
return false
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COST VALIDATION AND PAYMENT
|
||||
# =============================================================================
|
||||
|
||||
## Signal emitted when cost cannot be paid
|
||||
signal ability_cost_failed(source: CardInstance, ability: Dictionary, reason: String)
|
||||
|
||||
|
||||
## Validate that a player can pay the cost for an ability
|
||||
## Returns true if cost can be paid, false otherwise
|
||||
func _validate_ability_cost(
|
||||
cost: Dictionary,
|
||||
source: CardInstance,
|
||||
player
|
||||
) -> Dictionary:
|
||||
var result = {"valid": true, "reason": ""}
|
||||
|
||||
if cost.is_empty():
|
||||
return result
|
||||
|
||||
# Check CP cost
|
||||
var cp_cost = cost.get("cp", 0)
|
||||
var element = cost.get("element", "")
|
||||
|
||||
if cp_cost > 0:
|
||||
if element and element != "" and element.to_upper() != "ANY":
|
||||
# Specific element required
|
||||
var element_enum = Enums.element_from_string(element)
|
||||
if player.cp_pool.get_cp(element_enum) < cp_cost:
|
||||
result.valid = false
|
||||
result.reason = "Not enough %s CP (need %d, have %d)" % [
|
||||
element, cp_cost, player.cp_pool.get_cp(element_enum)
|
||||
]
|
||||
return result
|
||||
else:
|
||||
# Any element
|
||||
if player.cp_pool.get_total_cp() < cp_cost:
|
||||
result.valid = false
|
||||
result.reason = "Not enough CP (need %d, have %d)" % [
|
||||
cp_cost, player.cp_pool.get_total_cp()
|
||||
]
|
||||
return result
|
||||
|
||||
# Check discard cost
|
||||
var discard = cost.get("discard", 0)
|
||||
if discard > 0:
|
||||
if player.hand.get_count() < discard:
|
||||
result.valid = false
|
||||
result.reason = "Not enough cards in hand to discard (need %d, have %d)" % [
|
||||
discard, player.hand.get_count()
|
||||
]
|
||||
return result
|
||||
|
||||
# Check dull self cost
|
||||
var dull_self = cost.get("dull_self", false)
|
||||
if dull_self and source:
|
||||
if source.is_dull():
|
||||
result.valid = false
|
||||
result.reason = "Card is already dulled"
|
||||
return result
|
||||
|
||||
# Check specific card discard
|
||||
var specific_discard = cost.get("specific_discard", "")
|
||||
if specific_discard != "":
|
||||
# Player must have a card with this name in hand
|
||||
var has_card = false
|
||||
for card in player.hand.get_cards():
|
||||
if card.card_data and card.card_data.name.to_lower() == specific_discard.to_lower():
|
||||
has_card = true
|
||||
break
|
||||
if not has_card:
|
||||
result.valid = false
|
||||
result.reason = "Must discard a card named '%s'" % specific_discard
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Pay the cost for an ability
|
||||
## Returns true if cost was paid successfully
|
||||
func _pay_ability_cost(
|
||||
cost: Dictionary,
|
||||
source: CardInstance,
|
||||
player
|
||||
) -> bool:
|
||||
if cost.is_empty():
|
||||
return true
|
||||
|
||||
# Pay CP cost
|
||||
var cp_cost = cost.get("cp", 0)
|
||||
var element = cost.get("element", "")
|
||||
|
||||
if cp_cost > 0:
|
||||
if element and element != "" and element.to_upper() != "ANY":
|
||||
# Spend specific element CP
|
||||
var element_enum = Enums.element_from_string(element)
|
||||
player.cp_pool.add_cp(element_enum, -cp_cost)
|
||||
else:
|
||||
# Spend from any element (generic)
|
||||
var remaining = cp_cost
|
||||
for elem in Enums.Element.values():
|
||||
var available = player.cp_pool.get_cp(elem)
|
||||
if available > 0:
|
||||
var to_spend = mini(available, remaining)
|
||||
player.cp_pool.add_cp(elem, -to_spend)
|
||||
remaining -= to_spend
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
# Pay dull self cost
|
||||
var dull_self = cost.get("dull_self", false)
|
||||
if dull_self and source:
|
||||
source.dull()
|
||||
|
||||
# Note: Discard costs are handled through separate UI interaction
|
||||
# The discard selection would be queued as a separate effect
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Called when player selects mode(s)
|
||||
func _on_modes_selected(
|
||||
effect: Dictionary,
|
||||
modes: Array,
|
||||
selected_indices: Array,
|
||||
source: CardInstance
|
||||
) -> void:
|
||||
# Queue the effects from selected modes
|
||||
for index in selected_indices:
|
||||
if index >= 0 and index < modes.size():
|
||||
var mode = modes[index]
|
||||
var mode_effects = mode.get("effects", [])
|
||||
|
||||
for mode_effect in mode_effects:
|
||||
_pending_effects.push_back({
|
||||
"effect": mode_effect,
|
||||
"source": source,
|
||||
"controller": source.controller_index if source else 0,
|
||||
"ability": effect,
|
||||
"event_data": {}
|
||||
})
|
||||
|
||||
# Remove the CHOOSE_MODE effect from pending and continue
|
||||
if not _pending_effects.is_empty():
|
||||
_pending_effects.pop_front()
|
||||
|
||||
_resolve_next_effect()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Event Handlers
|
||||
# =============================================================================
|
||||
|
||||
func _on_card_played(card: CardInstance, player_index: int) -> void:
|
||||
# Register field abilities
|
||||
if card.is_forward() or card.is_backup():
|
||||
var card_abilities = get_abilities(card.card_data.id)
|
||||
field_effect_manager.register_field_abilities(card, card_abilities)
|
||||
|
||||
# Trigger enters field events
|
||||
process_event("ENTERS_FIELD", {
|
||||
"card": card,
|
||||
"player": player_index,
|
||||
"zone_from": Enums.ZoneType.HAND,
|
||||
"zone_to": Enums.ZoneType.FIELD_FORWARDS if card.is_forward() else Enums.ZoneType.FIELD_BACKUPS
|
||||
})
|
||||
|
||||
|
||||
func _on_summon_cast(card: CardInstance, player_index: int) -> void:
|
||||
process_event("SUMMON_CAST", {
|
||||
"card": card,
|
||||
"player": player_index
|
||||
})
|
||||
|
||||
|
||||
func _on_attack_declared(attacker: CardInstance) -> void:
|
||||
process_event("ATTACKS", {
|
||||
"card": attacker,
|
||||
"player": attacker.controller_index
|
||||
})
|
||||
|
||||
|
||||
func _on_block_declared(blocker: CardInstance) -> void:
|
||||
if not _game_state or not _game_state.turn_manager:
|
||||
return
|
||||
|
||||
var attacker = _game_state.turn_manager.current_attacker
|
||||
|
||||
process_event("BLOCKS", {
|
||||
"card": blocker,
|
||||
"attacker": attacker,
|
||||
"player": blocker.controller_index
|
||||
})
|
||||
|
||||
if attacker:
|
||||
process_event("IS_BLOCKED", {
|
||||
"card": attacker,
|
||||
"blocker": blocker,
|
||||
"player": attacker.controller_index
|
||||
})
|
||||
|
||||
|
||||
func _on_forward_broken(card: CardInstance) -> void:
|
||||
# Unregister field abilities
|
||||
field_effect_manager.unregister_field_abilities(card)
|
||||
|
||||
process_event("LEAVES_FIELD", {
|
||||
"card": card,
|
||||
"zone_from": Enums.ZoneType.FIELD_FORWARDS,
|
||||
"zone_to": Enums.ZoneType.BREAK
|
||||
})
|
||||
|
||||
|
||||
func _on_damage_dealt(player_index: int, amount: int, cards: Array) -> void:
|
||||
process_event("DAMAGE_DEALT_TO_PLAYER", {
|
||||
"player": player_index,
|
||||
"amount": amount,
|
||||
"cards": cards
|
||||
})
|
||||
|
||||
# Check for EX BURST triggers on damage cards
|
||||
for card in cards:
|
||||
if card.card_data and card.card_data.has_ex_burst:
|
||||
_trigger_ex_burst(card, player_index)
|
||||
|
||||
|
||||
func _on_card_moved(card: CardInstance, from_zone: Enums.ZoneType, to_zone: Enums.ZoneType) -> void:
|
||||
# Handle zone changes
|
||||
if to_zone == Enums.ZoneType.BREAK:
|
||||
field_effect_manager.unregister_field_abilities(card)
|
||||
|
||||
|
||||
func _on_combat_resolved(attacker: CardInstance, blocker: CardInstance) -> void:
|
||||
if not blocker:
|
||||
# Unblocked attack
|
||||
process_event("DEALS_DAMAGE_TO_OPPONENT", {
|
||||
"card": attacker,
|
||||
"player": attacker.controller_index
|
||||
})
|
||||
else:
|
||||
# Blocked combat
|
||||
process_event("DEALS_DAMAGE", {
|
||||
"card": attacker,
|
||||
"target": blocker,
|
||||
"player": attacker.controller_index
|
||||
})
|
||||
process_event("DEALS_DAMAGE", {
|
||||
"card": blocker,
|
||||
"target": attacker,
|
||||
"player": blocker.controller_index
|
||||
})
|
||||
|
||||
|
||||
func _on_phase_changed(phase: Enums.TurnPhase) -> void:
|
||||
var event_type = ""
|
||||
match phase:
|
||||
Enums.TurnPhase.ACTIVE:
|
||||
event_type = "START_OF_ACTIVE_PHASE"
|
||||
Enums.TurnPhase.DRAW:
|
||||
event_type = "START_OF_DRAW_PHASE"
|
||||
Enums.TurnPhase.MAIN_1:
|
||||
event_type = "START_OF_MAIN_PHASE"
|
||||
Enums.TurnPhase.ATTACK:
|
||||
event_type = "START_OF_ATTACK_PHASE"
|
||||
Enums.TurnPhase.MAIN_2:
|
||||
event_type = "START_OF_MAIN_PHASE_2"
|
||||
Enums.TurnPhase.END:
|
||||
event_type = "START_OF_END_PHASE"
|
||||
|
||||
if event_type:
|
||||
process_event(event_type, {
|
||||
"player": _game_state.turn_manager.current_player_index if _game_state else 0
|
||||
})
|
||||
|
||||
|
||||
func _on_turn_started(player_index: int, turn_number: int) -> void:
|
||||
process_event("START_OF_TURN", {
|
||||
"player": player_index,
|
||||
"turn_number": turn_number
|
||||
})
|
||||
|
||||
|
||||
func _on_turn_ended(player_index: int) -> void:
|
||||
process_event("END_OF_TURN", {
|
||||
"player": player_index
|
||||
})
|
||||
|
||||
|
||||
## Trigger EX BURST for a damage card
|
||||
func _trigger_ex_burst(card: CardInstance, damaged_player: int) -> void:
|
||||
var card_abilities = get_abilities(card.card_data.id)
|
||||
|
||||
for ability in card_abilities:
|
||||
var parsed = ability.get("parsed", {})
|
||||
if parsed.get("is_ex_burst", false):
|
||||
# Queue the EX BURST ability
|
||||
_queue_ability({
|
||||
"source": card,
|
||||
"ability": ability,
|
||||
"event_data": {
|
||||
"player": damaged_player,
|
||||
"trigger_type": "EX_BURST"
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
|
||||
## Get power modifier from field effects for a card
|
||||
func get_field_power_modifier(card: CardInstance) -> int:
|
||||
return field_effect_manager.get_power_modifiers(card, _game_state)
|
||||
|
||||
|
||||
## Check if a card has a field-granted keyword
|
||||
func has_field_keyword(card: CardInstance, keyword: String) -> bool:
|
||||
return field_effect_manager.has_keyword(card, keyword, _game_state)
|
||||
|
||||
|
||||
## Check if a card has field-granted protection
|
||||
func has_field_protection(card: CardInstance, protection_type: String) -> bool:
|
||||
return field_effect_manager.has_protection(card, protection_type, _game_state)
|
||||
|
||||
|
||||
## Get all granted keywords for a card from field effects
|
||||
func get_field_keywords(card: CardInstance) -> Array:
|
||||
return field_effect_manager.get_granted_keywords(card, _game_state)
|
||||
|
||||
|
||||
## Trigger EX BURST on a specific card (called by EffectResolver)
|
||||
func trigger_ex_burst_on_card(card: CardInstance) -> void:
|
||||
if not card or not card.card_data:
|
||||
return
|
||||
|
||||
var card_abilities = get_abilities(card.card_data.id)
|
||||
|
||||
for ability in card_abilities:
|
||||
var parsed = ability.get("parsed", {})
|
||||
if parsed.get("is_ex_burst", false):
|
||||
_queue_ability({
|
||||
"source": card,
|
||||
"ability": ability,
|
||||
"event_data": {
|
||||
"trigger_type": "EX_BURST_TRIGGERED"
|
||||
}
|
||||
})
|
||||
break
|
||||
219
scripts/game/abilities/CardFilter.gd
Normal file
219
scripts/game/abilities/CardFilter.gd
Normal file
@@ -0,0 +1,219 @@
|
||||
class_name CardFilter
|
||||
extends RefCounted
|
||||
|
||||
## CardFilter - Shared card filtering utility used by EffectResolver, FieldEffectManager, and TargetSelector
|
||||
##
|
||||
## This utility provides a unified way to filter cards based on various criteria
|
||||
## including element, job, category, cost, power, card type, and state.
|
||||
|
||||
|
||||
## Check if a card matches a filter dictionary
|
||||
static func matches_filter(card: CardInstance, filter: Dictionary, source: CardInstance = null) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
if filter.is_empty():
|
||||
return true
|
||||
|
||||
# Element filter
|
||||
if filter.has("element"):
|
||||
var element_str = str(filter.element).to_upper()
|
||||
var element = Enums.element_from_string(element_str)
|
||||
if element not in card.get_elements():
|
||||
return false
|
||||
|
||||
# Job filter
|
||||
if filter.has("job"):
|
||||
var job_filter = str(filter.job).to_lower()
|
||||
var card_job = str(card.card_data.job).to_lower() if card.card_data.job else ""
|
||||
if job_filter != card_job:
|
||||
return false
|
||||
|
||||
# Category filter
|
||||
if filter.has("category"):
|
||||
if not _has_category(card, str(filter.category).to_upper()):
|
||||
return false
|
||||
|
||||
# Cost filters
|
||||
if not _matches_cost_filter(card, filter):
|
||||
return false
|
||||
|
||||
# Power filters
|
||||
if not _matches_power_filter(card, filter):
|
||||
return false
|
||||
|
||||
# Card name filter
|
||||
if filter.has("card_name"):
|
||||
var name_filter = str(filter.card_name).to_lower()
|
||||
var card_name = str(card.card_data.name).to_lower() if card.card_data.name else ""
|
||||
if name_filter != card_name:
|
||||
return false
|
||||
if filter.has("name"):
|
||||
var name_filter = str(filter.name).to_lower()
|
||||
var card_name = str(card.card_data.name).to_lower() if card.card_data.name else ""
|
||||
if name_filter != card_name:
|
||||
return false
|
||||
|
||||
# Card type filter
|
||||
if filter.has("card_type"):
|
||||
if not _matches_type(card, str(filter.card_type)):
|
||||
return false
|
||||
|
||||
# State filters
|
||||
if filter.has("is_dull"):
|
||||
var card_dull = card.is_dull() if card.has_method("is_dull") else card.is_dull
|
||||
if card_dull != filter.is_dull:
|
||||
return false
|
||||
if filter.has("is_active"):
|
||||
var card_active = card.is_active() if card.has_method("is_active") else not card.is_dull
|
||||
if card_active != filter.is_active:
|
||||
return false
|
||||
|
||||
# Exclude self
|
||||
if filter.get("exclude_self", false) and source != null and card == source:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Count cards that match a filter
|
||||
static func count_matching(cards: Array, filter: Dictionary, source: CardInstance = null) -> int:
|
||||
var count = 0
|
||||
for card in cards:
|
||||
if matches_filter(card, filter, source):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Get all cards that match a filter
|
||||
static func get_matching(cards: Array, filter: Dictionary, source: CardInstance = null) -> Array:
|
||||
var matching: Array = []
|
||||
for card in cards:
|
||||
if matches_filter(card, filter, source):
|
||||
matching.append(card)
|
||||
return matching
|
||||
|
||||
|
||||
## Get the highest power among cards (optionally filtered)
|
||||
static func get_highest_power(cards: Array, filter: Dictionary = {}, source: CardInstance = null) -> int:
|
||||
var highest = 0
|
||||
for card in cards:
|
||||
if filter.is_empty() or matches_filter(card, filter, source):
|
||||
var power = card.get_power() if card.has_method("get_power") else 0
|
||||
if power > highest:
|
||||
highest = power
|
||||
return highest
|
||||
|
||||
|
||||
## Get the lowest power among cards (optionally filtered)
|
||||
static func get_lowest_power(cards: Array, filter: Dictionary = {}, source: CardInstance = null) -> int:
|
||||
var lowest = -1
|
||||
for card in cards:
|
||||
if filter.is_empty() or matches_filter(card, filter, source):
|
||||
var power = card.get_power() if card.has_method("get_power") else 0
|
||||
if lowest == -1 or power < lowest:
|
||||
lowest = power
|
||||
return lowest if lowest != -1 else 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRIVATE HELPER METHODS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Check if a card has a specific category
|
||||
static func _has_category(card: CardInstance, category_filter: String) -> bool:
|
||||
# Check card's category field
|
||||
if card.card_data.has("category") and card.card_data.category:
|
||||
if category_filter in str(card.card_data.category).to_upper():
|
||||
return true
|
||||
# Check categories array if present
|
||||
if card.card_data.has("categories"):
|
||||
for cat in card.card_data.categories:
|
||||
if category_filter in str(cat).to_upper():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Check if a card matches cost filter criteria
|
||||
static func _matches_cost_filter(card: CardInstance, filter: Dictionary) -> bool:
|
||||
var card_cost = card.card_data.cost
|
||||
|
||||
# Exact cost filter
|
||||
if filter.has("cost") and not filter.has("cost_comparison"):
|
||||
if card_cost != int(filter.cost):
|
||||
return false
|
||||
|
||||
# Min/max style cost filters (from TargetSelector)
|
||||
if filter.has("cost_min") and card_cost < int(filter.cost_min):
|
||||
return false
|
||||
if filter.has("cost_max") and card_cost > int(filter.cost_max):
|
||||
return false
|
||||
|
||||
# Cost comparison filter
|
||||
if filter.has("cost_comparison") and filter.has("cost_value"):
|
||||
var target_cost = int(filter.cost_value)
|
||||
match str(filter.cost_comparison).to_upper():
|
||||
"LTE":
|
||||
if card_cost > target_cost:
|
||||
return false
|
||||
"GTE":
|
||||
if card_cost < target_cost:
|
||||
return false
|
||||
"EQ":
|
||||
if card_cost != target_cost:
|
||||
return false
|
||||
"LT":
|
||||
if card_cost >= target_cost:
|
||||
return false
|
||||
"GT":
|
||||
if card_cost <= target_cost:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Check if a card matches type filter
|
||||
static func _matches_type(card: CardInstance, type_filter: String) -> bool:
|
||||
match type_filter.to_upper():
|
||||
"FORWARD":
|
||||
return card.is_forward()
|
||||
"BACKUP":
|
||||
return card.is_backup()
|
||||
"SUMMON":
|
||||
return card.is_summon()
|
||||
"MONSTER":
|
||||
return card.is_monster()
|
||||
"CHARACTER":
|
||||
return card.is_forward() or card.is_backup()
|
||||
return true
|
||||
|
||||
|
||||
## Check if a card matches power filter criteria
|
||||
static func _matches_power_filter(card: CardInstance, filter: Dictionary) -> bool:
|
||||
var power = card.get_power() if card.has_method("get_power") else card.card_data.power
|
||||
|
||||
# Min/max style power filters
|
||||
if filter.has("power_min") and power < int(filter.power_min):
|
||||
return false
|
||||
if filter.has("power_max") and power > int(filter.power_max):
|
||||
return false
|
||||
|
||||
# Comparison style power filter
|
||||
if filter.has("power_comparison") and filter.has("power_value"):
|
||||
var target = int(filter.power_value)
|
||||
match str(filter.power_comparison).to_upper():
|
||||
"LTE":
|
||||
if power > target:
|
||||
return false
|
||||
"GTE":
|
||||
if power < target:
|
||||
return false
|
||||
"LT":
|
||||
if power >= target:
|
||||
return false
|
||||
"GT":
|
||||
if power <= target:
|
||||
return false
|
||||
|
||||
return true
|
||||
510
scripts/game/abilities/ConditionChecker.gd
Normal file
510
scripts/game/abilities/ConditionChecker.gd
Normal file
@@ -0,0 +1,510 @@
|
||||
class_name ConditionChecker
|
||||
extends RefCounted
|
||||
|
||||
## Centralized condition evaluation for all ability types
|
||||
## Handles conditions like "If you control X", "If you have received Y damage", etc.
|
||||
|
||||
|
||||
## Main evaluation entry point
|
||||
## Returns true if condition is met, false otherwise
|
||||
func evaluate(condition: Dictionary, context: Dictionary) -> bool:
|
||||
if condition.is_empty():
|
||||
return true # Empty condition = unconditional
|
||||
|
||||
var condition_type = condition.get("type", "")
|
||||
|
||||
match condition_type:
|
||||
"CONTROL_CARD":
|
||||
return _check_control_card(condition, context)
|
||||
"CONTROL_COUNT":
|
||||
return _check_control_count(condition, context)
|
||||
"DAMAGE_RECEIVED":
|
||||
return _check_damage_received(condition, context)
|
||||
"BREAK_ZONE_COUNT":
|
||||
return _check_break_zone_count(condition, context)
|
||||
"CARD_IN_ZONE":
|
||||
return _check_card_in_zone(condition, context)
|
||||
"FORWARD_STATE":
|
||||
return _check_forward_state(condition, context)
|
||||
"COST_COMPARISON":
|
||||
return _check_cost_comparison(condition, context)
|
||||
"POWER_COMPARISON":
|
||||
return _check_power_comparison(condition, context)
|
||||
"ELEMENT_MATCH":
|
||||
return _check_element_match(condition, context)
|
||||
"CARD_TYPE_MATCH":
|
||||
return _check_card_type_match(condition, context)
|
||||
"JOB_MATCH":
|
||||
return _check_job_match(condition, context)
|
||||
"CATEGORY_MATCH":
|
||||
return _check_category_match(condition, context)
|
||||
"AND":
|
||||
return _check_and(condition, context)
|
||||
"OR":
|
||||
return _check_or(condition, context)
|
||||
"NOT":
|
||||
return _check_not(condition, context)
|
||||
_:
|
||||
push_warning("ConditionChecker: Unknown condition type '%s'" % condition_type)
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTROL CONDITIONS
|
||||
# =============================================================================
|
||||
|
||||
func _check_control_card(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var card_name = condition.get("card_name", "")
|
||||
var player = context.get("player_id", 0)
|
||||
var game_state = context.get("game_state")
|
||||
|
||||
if not game_state:
|
||||
return false
|
||||
|
||||
# Check all field cards for the player
|
||||
var field_cards = _get_field_cards(game_state, player)
|
||||
for card in field_cards:
|
||||
if card and card.card_data and card.card_data.name == card_name:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _check_control_count(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var card_type = condition.get("card_type", "")
|
||||
var element = condition.get("element", "")
|
||||
var job = condition.get("job", "")
|
||||
var category = condition.get("category", "")
|
||||
var comparison = condition.get("comparison", "GTE")
|
||||
var value = condition.get("value", 1)
|
||||
var player = context.get("player_id", 0)
|
||||
var game_state = context.get("game_state")
|
||||
|
||||
if not game_state:
|
||||
return false
|
||||
|
||||
var count = 0
|
||||
var field_cards = _get_field_cards(game_state, player)
|
||||
|
||||
for card in field_cards:
|
||||
if not card or not card.card_data:
|
||||
continue
|
||||
|
||||
var matches = true
|
||||
|
||||
# Check card type filter
|
||||
if card_type != "" and not _matches_card_type(card, card_type):
|
||||
matches = false
|
||||
|
||||
# Check element filter
|
||||
if element != "" and not _matches_element(card, element):
|
||||
matches = false
|
||||
|
||||
# Check job filter
|
||||
if job != "" and not _matches_job(card, job):
|
||||
matches = false
|
||||
|
||||
# Check category filter
|
||||
if category != "" and not _matches_category(card, category):
|
||||
matches = false
|
||||
|
||||
if matches:
|
||||
count += 1
|
||||
|
||||
return _compare(count, comparison, value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DAMAGE CONDITIONS
|
||||
# =============================================================================
|
||||
|
||||
func _check_damage_received(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var comparison = condition.get("comparison", "GTE")
|
||||
var value = condition.get("value", 1)
|
||||
var player = context.get("player_id", 0)
|
||||
var game_state = context.get("game_state")
|
||||
|
||||
if not game_state:
|
||||
return false
|
||||
|
||||
var damage = _get_player_damage(game_state, player)
|
||||
return _compare(damage, comparison, value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ZONE CONDITIONS
|
||||
# =============================================================================
|
||||
|
||||
func _check_break_zone_count(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var card_name = condition.get("card_name", "")
|
||||
var card_names: Array = condition.get("card_names", [])
|
||||
if card_name != "" and card_name not in card_names:
|
||||
card_names.append(card_name)
|
||||
|
||||
var comparison = condition.get("comparison", "GTE")
|
||||
var value = condition.get("value", 1)
|
||||
var player = context.get("player_id", 0)
|
||||
var game_state = context.get("game_state")
|
||||
|
||||
if not game_state:
|
||||
return false
|
||||
|
||||
var count = 0
|
||||
var break_zone = _get_break_zone(game_state, player)
|
||||
|
||||
for card in break_zone:
|
||||
if not card or not card.card_data:
|
||||
continue
|
||||
|
||||
# If no specific names, count all
|
||||
if card_names.is_empty():
|
||||
count += 1
|
||||
elif card.card_data.name in card_names:
|
||||
count += 1
|
||||
|
||||
return _compare(count, comparison, value)
|
||||
|
||||
|
||||
func _check_card_in_zone(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var zone = condition.get("zone", "") # "HAND", "DECK", "BREAK_ZONE", "REMOVED"
|
||||
var card_name = condition.get("card_name", "")
|
||||
var card_type = condition.get("card_type", "")
|
||||
var player = context.get("player_id", 0)
|
||||
var game_state = context.get("game_state")
|
||||
|
||||
if not game_state:
|
||||
return false
|
||||
|
||||
var zone_cards: Array = []
|
||||
match zone:
|
||||
"HAND":
|
||||
zone_cards = _get_hand(game_state, player)
|
||||
"DECK":
|
||||
zone_cards = _get_deck(game_state, player)
|
||||
"BREAK_ZONE":
|
||||
zone_cards = _get_break_zone(game_state, player)
|
||||
"REMOVED":
|
||||
zone_cards = _get_removed_zone(game_state, player)
|
||||
"FIELD":
|
||||
zone_cards = _get_field_cards(game_state, player)
|
||||
|
||||
for card in zone_cards:
|
||||
if not card or not card.card_data:
|
||||
continue
|
||||
|
||||
var matches = true
|
||||
if card_name != "" and card.card_data.name != card_name:
|
||||
matches = false
|
||||
if card_type != "" and not _matches_card_type(card, card_type):
|
||||
matches = false
|
||||
|
||||
if matches:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CARD STATE CONDITIONS
|
||||
# =============================================================================
|
||||
|
||||
func _check_forward_state(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var state = condition.get("state", "") # "DULL", "ACTIVE", "DAMAGED"
|
||||
var check_self = condition.get("check_self", false)
|
||||
var target = context.get("target_card") if not check_self else context.get("source_card")
|
||||
|
||||
if not target:
|
||||
return false
|
||||
|
||||
match state:
|
||||
"DULL":
|
||||
return target.is_dull if target.has_method("get") or "is_dull" in target else false
|
||||
"ACTIVE":
|
||||
return not target.is_dull if "is_dull" in target else false
|
||||
"DAMAGED":
|
||||
if "current_power" in target and target.card_data:
|
||||
return target.current_power < target.card_data.power
|
||||
"FROZEN":
|
||||
return target.is_frozen if "is_frozen" in target else false
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _check_cost_comparison(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var comparison = condition.get("comparison", "LTE")
|
||||
var value = condition.get("value", 0)
|
||||
var compare_to = condition.get("compare_to", "") # "SELF_COST", "VALUE", or empty for value
|
||||
var target = context.get("target_card")
|
||||
var source = context.get("source_card")
|
||||
|
||||
if not target or not target.card_data:
|
||||
return false
|
||||
|
||||
var target_cost = target.card_data.cost
|
||||
var compare_value = value
|
||||
|
||||
if compare_to == "SELF_COST" and source and source.card_data:
|
||||
compare_value = source.card_data.cost
|
||||
|
||||
return _compare(target_cost, comparison, compare_value)
|
||||
|
||||
|
||||
func _check_power_comparison(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var comparison = condition.get("comparison", "LTE")
|
||||
var value = condition.get("value", 0)
|
||||
var compare_to = condition.get("compare_to", "") # "SELF_POWER", "VALUE"
|
||||
var target = context.get("target_card")
|
||||
var source = context.get("source_card")
|
||||
|
||||
if not target:
|
||||
return false
|
||||
|
||||
var target_power = target.current_power if "current_power" in target else 0
|
||||
var compare_value = value
|
||||
|
||||
if compare_to == "SELF_POWER" and source:
|
||||
compare_value = source.current_power if "current_power" in source else 0
|
||||
|
||||
return _compare(target_power, comparison, compare_value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CARD ATTRIBUTE CONDITIONS
|
||||
# =============================================================================
|
||||
|
||||
func _check_element_match(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var element = condition.get("element", "")
|
||||
var check_self = condition.get("check_self", false)
|
||||
var target = context.get("target_card") if not check_self else context.get("source_card")
|
||||
|
||||
if not target or not target.card_data:
|
||||
return false
|
||||
|
||||
return _matches_element(target, element)
|
||||
|
||||
|
||||
func _check_card_type_match(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var card_type = condition.get("card_type", "")
|
||||
var check_self = condition.get("check_self", false)
|
||||
var target = context.get("target_card") if not check_self else context.get("source_card")
|
||||
|
||||
if not target:
|
||||
return false
|
||||
|
||||
return _matches_card_type(target, card_type)
|
||||
|
||||
|
||||
func _check_job_match(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var job = condition.get("job", "")
|
||||
var check_self = condition.get("check_self", false)
|
||||
var target = context.get("target_card") if not check_self else context.get("source_card")
|
||||
|
||||
if not target or not target.card_data:
|
||||
return false
|
||||
|
||||
return _matches_job(target, job)
|
||||
|
||||
|
||||
func _check_category_match(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var category = condition.get("category", "")
|
||||
var check_self = condition.get("check_self", false)
|
||||
var target = context.get("target_card") if not check_self else context.get("source_card")
|
||||
|
||||
if not target or not target.card_data:
|
||||
return false
|
||||
|
||||
return _matches_category(target, category)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LOGICAL OPERATORS
|
||||
# =============================================================================
|
||||
|
||||
func _check_and(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var conditions: Array = condition.get("conditions", [])
|
||||
for sub_condition in conditions:
|
||||
if not evaluate(sub_condition, context):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _check_or(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var conditions: Array = condition.get("conditions", [])
|
||||
for sub_condition in conditions:
|
||||
if evaluate(sub_condition, context):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _check_not(condition: Dictionary, context: Dictionary) -> bool:
|
||||
var inner: Dictionary = condition.get("condition", {})
|
||||
return not evaluate(inner, context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _compare(actual: int, comparison: String, expected: int) -> bool:
|
||||
match comparison:
|
||||
"EQ":
|
||||
return actual == expected
|
||||
"NEQ":
|
||||
return actual != expected
|
||||
"GT":
|
||||
return actual > expected
|
||||
"GTE":
|
||||
return actual >= expected
|
||||
"LT":
|
||||
return actual < expected
|
||||
"LTE":
|
||||
return actual <= expected
|
||||
return false
|
||||
|
||||
|
||||
func _matches_card_type(card, card_type: String) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
var type_upper = card_type.to_upper()
|
||||
var card_type_value = card.card_data.type
|
||||
|
||||
# Handle string or enum type
|
||||
if card_type_value is String:
|
||||
return card_type_value.to_upper() == type_upper
|
||||
|
||||
# Handle Enums.CardType enum
|
||||
match type_upper:
|
||||
"FORWARD":
|
||||
return card_type_value == Enums.CardType.FORWARD
|
||||
"BACKUP":
|
||||
return card_type_value == Enums.CardType.BACKUP
|
||||
"SUMMON":
|
||||
return card_type_value == Enums.CardType.SUMMON
|
||||
"MONSTER":
|
||||
return card_type_value == Enums.CardType.MONSTER
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _matches_element(card, element: String) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
var element_upper = element.to_upper()
|
||||
var card_element = card.card_data.element
|
||||
|
||||
if card_element is String:
|
||||
return card_element.to_upper() == element_upper
|
||||
|
||||
# Handle Enums.Element enum
|
||||
match element_upper:
|
||||
"FIRE":
|
||||
return card_element == Enums.Element.FIRE
|
||||
"ICE":
|
||||
return card_element == Enums.Element.ICE
|
||||
"WIND":
|
||||
return card_element == Enums.Element.WIND
|
||||
"EARTH":
|
||||
return card_element == Enums.Element.EARTH
|
||||
"LIGHTNING":
|
||||
return card_element == Enums.Element.LIGHTNING
|
||||
"WATER":
|
||||
return card_element == Enums.Element.WATER
|
||||
"LIGHT":
|
||||
return card_element == Enums.Element.LIGHT
|
||||
"DARK":
|
||||
return card_element == Enums.Element.DARK
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _matches_job(card, job: String) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
var card_job = card.card_data.get("job", "")
|
||||
if card_job is String:
|
||||
return card_job.to_lower() == job.to_lower()
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _matches_category(card, category: String) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
var card_categories = card.card_data.get("categories", [])
|
||||
if card_categories is Array:
|
||||
for cat in card_categories:
|
||||
if cat is String and cat.to_lower() == category.to_lower():
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GAME STATE ACCESSORS
|
||||
# These abstract away the game state interface for flexibility
|
||||
# =============================================================================
|
||||
|
||||
func _get_field_cards(game_state, player: int) -> Array:
|
||||
if game_state.has_method("get_field_cards"):
|
||||
return game_state.get_field_cards(player)
|
||||
elif game_state.has_method("get_player_field"):
|
||||
return game_state.get_player_field(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "field" in p:
|
||||
return p.field
|
||||
return []
|
||||
|
||||
|
||||
func _get_player_damage(game_state, player: int) -> int:
|
||||
if game_state.has_method("get_player_damage"):
|
||||
return game_state.get_player_damage(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "damage" in p:
|
||||
return p.damage
|
||||
return 0
|
||||
|
||||
|
||||
func _get_break_zone(game_state, player: int) -> Array:
|
||||
if game_state.has_method("get_break_zone"):
|
||||
return game_state.get_break_zone(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "break_zone" in p:
|
||||
return p.break_zone
|
||||
return []
|
||||
|
||||
|
||||
func _get_hand(game_state, player: int) -> Array:
|
||||
if game_state.has_method("get_hand"):
|
||||
return game_state.get_hand(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "hand" in p:
|
||||
return p.hand
|
||||
return []
|
||||
|
||||
|
||||
func _get_deck(game_state, player: int) -> Array:
|
||||
if game_state.has_method("get_deck"):
|
||||
return game_state.get_deck(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "deck" in p:
|
||||
return p.deck
|
||||
return []
|
||||
|
||||
|
||||
func _get_removed_zone(game_state, player: int) -> Array:
|
||||
if game_state.has_method("get_removed_zone"):
|
||||
return game_state.get_removed_zone(player)
|
||||
elif "players" in game_state and player < game_state.players.size():
|
||||
var p = game_state.players[player]
|
||||
if "removed_zone" in p:
|
||||
return p.removed_zone
|
||||
return []
|
||||
1807
scripts/game/abilities/EffectResolver.gd
Normal file
1807
scripts/game/abilities/EffectResolver.gd
Normal file
File diff suppressed because it is too large
Load Diff
681
scripts/game/abilities/FieldEffectManager.gd
Normal file
681
scripts/game/abilities/FieldEffectManager.gd
Normal file
@@ -0,0 +1,681 @@
|
||||
class_name FieldEffectManager
|
||||
extends RefCounted
|
||||
|
||||
## FieldEffectManager - Manages continuous FIELD abilities
|
||||
## Tracks active field effects and calculates their impact on the game state
|
||||
|
||||
# Active field abilities by source card instance_id
|
||||
var _active_abilities: Dictionary = {} # instance_id -> Array of abilities
|
||||
|
||||
|
||||
## Register field abilities when a card enters the field
|
||||
func register_field_abilities(card: CardInstance, abilities: Array) -> void:
|
||||
var field_abilities: Array = []
|
||||
|
||||
for ability in abilities:
|
||||
var parsed = ability.get("parsed", {})
|
||||
if parsed.get("type") == "FIELD":
|
||||
field_abilities.append({
|
||||
"ability": ability,
|
||||
"source": card
|
||||
})
|
||||
|
||||
if not field_abilities.is_empty():
|
||||
_active_abilities[card.instance_id] = field_abilities
|
||||
|
||||
|
||||
## Unregister field abilities when a card leaves the field
|
||||
func unregister_field_abilities(card: CardInstance) -> void:
|
||||
_active_abilities.erase(card.instance_id)
|
||||
|
||||
|
||||
## Get total power modifier for a card from all active field effects
|
||||
func get_power_modifiers(card: CardInstance, game_state) -> int:
|
||||
var total_modifier: int = 0
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "POWER_MOD":
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
total_modifier += effect.get("amount", 0)
|
||||
|
||||
return total_modifier
|
||||
|
||||
|
||||
## Check if a card has a keyword granted by field effects
|
||||
func has_keyword(card: CardInstance, keyword: String, game_state) -> bool:
|
||||
var keyword_upper = keyword.to_upper()
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "KEYWORD":
|
||||
var granted_keyword = str(effect.get("keyword", "")).to_upper()
|
||||
if granted_keyword == keyword_upper:
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Get all keywords granted to a card by field effects
|
||||
func get_granted_keywords(card: CardInstance, game_state) -> Array:
|
||||
var keywords: Array = []
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "KEYWORD":
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
var keyword = effect.get("keyword", "")
|
||||
if keyword and keyword not in keywords:
|
||||
keywords.append(keyword)
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
## Check if a card has protection from something via field effects
|
||||
func has_protection(card: CardInstance, protection_type: String, game_state) -> bool:
|
||||
var protection_upper = protection_type.to_upper()
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "PROTECTION":
|
||||
var from = str(effect.get("from", "")).to_upper()
|
||||
if from == protection_upper or from == "ALL":
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Check if a card is affected by a damage modifier
|
||||
func get_damage_modifier(card: CardInstance, game_state) -> int:
|
||||
var total_modifier: int = 0
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "DAMAGE_MODIFIER":
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
total_modifier += effect.get("amount", 0)
|
||||
|
||||
return total_modifier
|
||||
|
||||
|
||||
## Check if a card matches an effect's target specification
|
||||
func _card_matches_effect_target(
|
||||
card: CardInstance,
|
||||
effect: Dictionary,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> bool:
|
||||
var target = effect.get("target", {})
|
||||
if target.is_empty():
|
||||
# No target specified, assume applies to source only
|
||||
return card == source
|
||||
|
||||
var target_type = str(target.get("type", "")).to_upper()
|
||||
|
||||
# Check owner
|
||||
var owner = str(target.get("owner", "ANY")).to_upper()
|
||||
match owner:
|
||||
"CONTROLLER":
|
||||
if card.controller_index != source.controller_index:
|
||||
return false
|
||||
"OPPONENT":
|
||||
if card.controller_index == source.controller_index:
|
||||
return false
|
||||
# "ANY" matches all
|
||||
|
||||
# Check if applies to self
|
||||
if target_type == "SELF":
|
||||
return card == source
|
||||
|
||||
# Check if applies to all matching
|
||||
if target_type == "ALL":
|
||||
return _matches_filter(card, target.get("filter", {}), source)
|
||||
|
||||
# Default check filter
|
||||
return _matches_filter(card, target.get("filter", {}), source)
|
||||
|
||||
|
||||
## Check if a card matches a filter (duplicated from TargetSelector for independence)
|
||||
func _matches_filter(
|
||||
card: CardInstance,
|
||||
filter: Dictionary,
|
||||
source: CardInstance
|
||||
) -> bool:
|
||||
if filter.is_empty():
|
||||
return true
|
||||
|
||||
# Card type filter
|
||||
if filter.has("card_type"):
|
||||
var type_str = str(filter.card_type).to_upper()
|
||||
match type_str:
|
||||
"FORWARD":
|
||||
if not card.is_forward():
|
||||
return false
|
||||
"BACKUP":
|
||||
if not card.is_backup():
|
||||
return false
|
||||
"SUMMON":
|
||||
if not card.is_summon():
|
||||
return false
|
||||
"CHARACTER":
|
||||
if not (card.is_forward() or card.is_backup()):
|
||||
return false
|
||||
|
||||
# Element filter
|
||||
if filter.has("element"):
|
||||
var element_str = str(filter.element).to_upper()
|
||||
var element = Enums.element_from_string(element_str)
|
||||
if element not in card.get_elements():
|
||||
return false
|
||||
|
||||
# Cost filters
|
||||
if filter.has("cost_min") and card.card_data.cost < int(filter.cost_min):
|
||||
return false
|
||||
if filter.has("cost_max") and card.card_data.cost > int(filter.cost_max):
|
||||
return false
|
||||
if filter.has("cost") and card.card_data.cost != int(filter.cost):
|
||||
return false
|
||||
|
||||
# Power filters
|
||||
if filter.has("power_min") and card.get_power() < int(filter.power_min):
|
||||
return false
|
||||
if filter.has("power_max") and card.get_power() > int(filter.power_max):
|
||||
return false
|
||||
|
||||
# Name filter
|
||||
if filter.has("name") and card.card_data.name != filter.name:
|
||||
return false
|
||||
|
||||
# Category filter
|
||||
if filter.has("category") and card.card_data.category != filter.category:
|
||||
return false
|
||||
|
||||
# Job filter
|
||||
if filter.has("job") and card.card_data.job != filter.job:
|
||||
return false
|
||||
|
||||
# Exclude self
|
||||
if filter.get("exclude_self", false) and card == source:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Get count of active field abilities
|
||||
func get_active_ability_count() -> int:
|
||||
var count = 0
|
||||
for instance_id in _active_abilities:
|
||||
count += _active_abilities[instance_id].size()
|
||||
return count
|
||||
|
||||
|
||||
## Clear all active abilities (for game reset)
|
||||
func clear_all() -> void:
|
||||
_active_abilities.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BLOCK IMMUNITY CHECKS
|
||||
# =============================================================================
|
||||
|
||||
## Check if a card has block immunity (can't be blocked by certain cards)
|
||||
func has_block_immunity(card: CardInstance, potential_blocker: CardInstance, game_state) -> bool:
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var source = ability_data.source
|
||||
if source != card:
|
||||
continue
|
||||
|
||||
var ability = ability_data.ability
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "BLOCK_IMMUNITY":
|
||||
var condition = effect.get("condition", {})
|
||||
if _blocker_matches_immunity_condition(potential_blocker, condition, card):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Check if blocker matches the immunity condition
|
||||
func _blocker_matches_immunity_condition(
|
||||
blocker: CardInstance,
|
||||
condition: Dictionary,
|
||||
attacker: CardInstance
|
||||
) -> bool:
|
||||
if condition.is_empty():
|
||||
return true # Unconditional block immunity
|
||||
|
||||
var comparison = condition.get("comparison", "")
|
||||
var attribute = condition.get("attribute", "")
|
||||
var value = condition.get("value", 0)
|
||||
var compare_to = condition.get("compare_to", "")
|
||||
|
||||
var blocker_value = 0
|
||||
match attribute:
|
||||
"cost":
|
||||
blocker_value = blocker.card_data.cost if blocker.card_data else 0
|
||||
"power":
|
||||
blocker_value = blocker.get_power()
|
||||
|
||||
var compare_value = value
|
||||
if compare_to == "SELF_POWER":
|
||||
compare_value = attacker.get_power()
|
||||
|
||||
match comparison:
|
||||
"GTE":
|
||||
return blocker_value >= compare_value
|
||||
"GT":
|
||||
return blocker_value > compare_value
|
||||
"LTE":
|
||||
return blocker_value <= compare_value
|
||||
"LT":
|
||||
return blocker_value < compare_value
|
||||
"EQ":
|
||||
return blocker_value == compare_value
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ATTACK RESTRICTION CHECKS
|
||||
# =============================================================================
|
||||
|
||||
## Check if a card has attack restrictions
|
||||
func has_attack_restriction(card: CardInstance, game_state) -> bool:
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "RESTRICTION":
|
||||
var restriction = effect.get("restriction", "")
|
||||
if restriction in ["CANNOT_ATTACK", "CANNOT_ATTACK_OR_BLOCK"]:
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Check if a card has block restrictions
|
||||
func has_block_restriction(card: CardInstance, game_state) -> bool:
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "RESTRICTION":
|
||||
var restriction = effect.get("restriction", "")
|
||||
if restriction in ["CANNOT_BLOCK", "CANNOT_ATTACK_OR_BLOCK"]:
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TAUNT CHECKS (Must be targeted if possible)
|
||||
# =============================================================================
|
||||
|
||||
## Get cards that must be targeted by opponent's abilities if possible
|
||||
func get_taunt_targets(player_index: int, game_state) -> Array:
|
||||
var taunt_cards: Array = []
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "TAUNT":
|
||||
var target = effect.get("target", {})
|
||||
if target.get("type") == "SELF":
|
||||
if source.controller_index == player_index:
|
||||
taunt_cards.append(source)
|
||||
|
||||
return taunt_cards
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COST MODIFICATION
|
||||
# =============================================================================
|
||||
|
||||
## Get cost modifier for playing a card
|
||||
func get_cost_modifier(
|
||||
card_to_play: CardInstance,
|
||||
playing_player: int,
|
||||
game_state
|
||||
) -> int:
|
||||
var total_modifier = 0
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
var effect_type = effect.get("type", "")
|
||||
|
||||
if effect_type == "COST_REDUCTION":
|
||||
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
|
||||
total_modifier -= effect.get("amount", 0)
|
||||
|
||||
elif effect_type == "COST_REDUCTION_SCALING":
|
||||
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
|
||||
var reduction = _calculate_scaling_cost_reduction(effect, source, game_state)
|
||||
total_modifier -= reduction
|
||||
|
||||
elif effect_type == "COST_INCREASE":
|
||||
if _cost_effect_applies(effect, card_to_play, playing_player, source, game_state):
|
||||
total_modifier += effect.get("amount", 0)
|
||||
|
||||
return total_modifier
|
||||
|
||||
|
||||
## Check if a cost modification effect applies to a card being played
|
||||
func _cost_effect_applies(
|
||||
effect: Dictionary,
|
||||
card: CardInstance,
|
||||
player: int,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> bool:
|
||||
var for_player = effect.get("for_player", "CONTROLLER")
|
||||
|
||||
# Check if effect applies to this player
|
||||
match for_player:
|
||||
"CONTROLLER":
|
||||
if player != source.controller_index:
|
||||
return false
|
||||
"OPPONENT":
|
||||
if player == source.controller_index:
|
||||
return false
|
||||
|
||||
# Check card filter
|
||||
var card_filter = effect.get("card_filter", "")
|
||||
if card_filter and not _card_matches_name_filter(card, card_filter):
|
||||
return false
|
||||
|
||||
# Check condition
|
||||
var condition = effect.get("condition", {})
|
||||
if not condition.is_empty():
|
||||
if not _cost_condition_met(condition, source, game_state):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Check if a card name matches a filter
|
||||
func _card_matches_name_filter(card: CardInstance, filter_text: String) -> bool:
|
||||
if not card or not card.card_data:
|
||||
return false
|
||||
|
||||
var filter_lower = filter_text.to_lower()
|
||||
var card_name = card.card_data.name.to_lower()
|
||||
|
||||
# Direct name match
|
||||
if card_name in filter_lower or filter_lower in card_name:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Check if a cost condition is met
|
||||
func _cost_condition_met(condition: Dictionary, source: CardInstance, game_state) -> bool:
|
||||
if condition.has("control_card_name"):
|
||||
var name_to_find = condition.control_card_name.to_lower()
|
||||
var player = game_state.get_player(source.controller_index)
|
||||
if player:
|
||||
for card in player.field_forwards.get_cards():
|
||||
if name_to_find in card.card_data.name.to_lower():
|
||||
return true
|
||||
for card in player.field_backups.get_cards():
|
||||
if name_to_find in card.card_data.name.to_lower():
|
||||
return true
|
||||
return false
|
||||
|
||||
if condition.has("control_category"):
|
||||
var category = condition.control_category.to_lower()
|
||||
var player = game_state.get_player(source.controller_index)
|
||||
if player:
|
||||
for card in player.field_forwards.get_cards():
|
||||
if category in card.card_data.category.to_lower():
|
||||
return true
|
||||
for card in player.field_backups.get_cards():
|
||||
if category in card.card_data.category.to_lower():
|
||||
return true
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCALING COST REDUCTION
|
||||
# =============================================================================
|
||||
|
||||
## Calculate cost reduction for a COST_REDUCTION_SCALING effect
|
||||
func _calculate_scaling_cost_reduction(
|
||||
effect: Dictionary,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> int:
|
||||
var reduction_per = effect.get("reduction_per", 1)
|
||||
var scale_by = str(effect.get("scale_by", "")).to_upper()
|
||||
var scale_filter = effect.get("scale_filter", {})
|
||||
|
||||
# Get scale value using similar logic to EffectResolver
|
||||
var scale_value = _get_scale_value(scale_by, source, game_state, scale_filter)
|
||||
|
||||
return scale_value * reduction_per
|
||||
|
||||
|
||||
## Get scale value based on scale_by type (with optional filter)
|
||||
## Mirrors the logic in EffectResolver for consistency
|
||||
func _get_scale_value(
|
||||
scale_by: String,
|
||||
source: CardInstance,
|
||||
game_state,
|
||||
scale_filter: Dictionary = {}
|
||||
) -> int:
|
||||
if not source or not game_state:
|
||||
return 0
|
||||
|
||||
var player_index = source.controller_index
|
||||
var player = game_state.get_player(player_index)
|
||||
if not player:
|
||||
return 0
|
||||
|
||||
# Determine owner from filter (default to CONTROLLER)
|
||||
var owner = scale_filter.get("owner", "CONTROLLER").to_upper() if scale_filter else "CONTROLLER"
|
||||
|
||||
# Get cards based on scale_by and owner
|
||||
var cards_to_count: Array = []
|
||||
|
||||
match scale_by:
|
||||
"DAMAGE_RECEIVED":
|
||||
# Special case - not card-based
|
||||
return _get_damage_for_owner(owner, player_index, game_state)
|
||||
"FORWARDS_CONTROLLED", "FORWARDS":
|
||||
cards_to_count = _get_forwards_for_owner(owner, player_index, game_state)
|
||||
"BACKUPS_CONTROLLED", "BACKUPS":
|
||||
cards_to_count = _get_backups_for_owner(owner, player_index, game_state)
|
||||
"FIELD_CARDS_CONTROLLED", "FIELD_CARDS":
|
||||
cards_to_count = _get_field_cards_for_owner(owner, player_index, game_state)
|
||||
"CARDS_IN_HAND":
|
||||
cards_to_count = _get_hand_for_owner(owner, player_index, game_state)
|
||||
"CARDS_IN_BREAK_ZONE":
|
||||
cards_to_count = _get_break_zone_for_owner(owner, player_index, game_state)
|
||||
"OPPONENT_FORWARDS":
|
||||
cards_to_count = _get_forwards_for_owner("OPPONENT", player_index, game_state)
|
||||
"OPPONENT_BACKUPS":
|
||||
cards_to_count = _get_backups_for_owner("OPPONENT", player_index, game_state)
|
||||
_:
|
||||
push_warning("FieldEffectManager: Unknown scale_by type: " + scale_by)
|
||||
return 0
|
||||
|
||||
# If no filter, just return count
|
||||
if not scale_filter or scale_filter.is_empty() or (scale_filter.size() == 1 and scale_filter.has("owner")):
|
||||
return cards_to_count.size()
|
||||
|
||||
# Apply filter and count matching cards using CardFilter utility
|
||||
return CardFilter.count_matching(cards_to_count, scale_filter)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OWNER-BASED ACCESS HELPERS FOR SCALING
|
||||
# =============================================================================
|
||||
|
||||
func _get_forwards_for_owner(owner: String, player_index: int, game_state) -> Array:
|
||||
match owner.to_upper():
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(player_index)
|
||||
return player.field_forwards.get_cards() if player and player.field_forwards else []
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - player_index)
|
||||
return opponent.field_forwards.get_cards() if opponent and opponent.field_forwards else []
|
||||
_:
|
||||
var all_cards = []
|
||||
for p in game_state.players:
|
||||
if p and p.field_forwards:
|
||||
all_cards.append_array(p.field_forwards.get_cards())
|
||||
return all_cards
|
||||
|
||||
|
||||
func _get_backups_for_owner(owner: String, player_index: int, game_state) -> Array:
|
||||
match owner.to_upper():
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(player_index)
|
||||
return player.field_backups.get_cards() if player and player.field_backups else []
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - player_index)
|
||||
return opponent.field_backups.get_cards() if opponent and opponent.field_backups else []
|
||||
_:
|
||||
var all_cards = []
|
||||
for p in game_state.players:
|
||||
if p and p.field_backups:
|
||||
all_cards.append_array(p.field_backups.get_cards())
|
||||
return all_cards
|
||||
|
||||
|
||||
func _get_field_cards_for_owner(owner: String, player_index: int, game_state) -> Array:
|
||||
var cards = []
|
||||
cards.append_array(_get_forwards_for_owner(owner, player_index, game_state))
|
||||
cards.append_array(_get_backups_for_owner(owner, player_index, game_state))
|
||||
return cards
|
||||
|
||||
|
||||
func _get_hand_for_owner(owner: String, player_index: int, game_state) -> Array:
|
||||
match owner.to_upper():
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(player_index)
|
||||
return player.hand.get_cards() if player and player.hand else []
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - player_index)
|
||||
return opponent.hand.get_cards() if opponent and opponent.hand else []
|
||||
_:
|
||||
var all_cards = []
|
||||
for p in game_state.players:
|
||||
if p and p.hand:
|
||||
all_cards.append_array(p.hand.get_cards())
|
||||
return all_cards
|
||||
|
||||
|
||||
func _get_break_zone_for_owner(owner: String, player_index: int, game_state) -> Array:
|
||||
match owner.to_upper():
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(player_index)
|
||||
return player.break_zone.get_cards() if player and player.break_zone else []
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - player_index)
|
||||
return opponent.break_zone.get_cards() if opponent and opponent.break_zone else []
|
||||
_:
|
||||
var all_cards = []
|
||||
for p in game_state.players:
|
||||
if p and p.break_zone:
|
||||
all_cards.append_array(p.break_zone.get_cards())
|
||||
return all_cards
|
||||
|
||||
|
||||
func _get_damage_for_owner(owner: String, player_index: int, game_state) -> int:
|
||||
match owner.to_upper():
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(player_index)
|
||||
return player.damage if player and "damage" in player else 0
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - player_index)
|
||||
return opponent.damage if opponent and "damage" in opponent else 0
|
||||
_:
|
||||
var total = 0
|
||||
for p in game_state.players:
|
||||
if p and "damage" in p:
|
||||
total += p.damage
|
||||
return total
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MULTI-ATTACK CHECKS
|
||||
# =============================================================================
|
||||
|
||||
## Get maximum attacks allowed for a card this turn
|
||||
func get_max_attacks(card: CardInstance, game_state) -> int:
|
||||
var max_attacks = 1 # Default is 1 attack per turn
|
||||
|
||||
for instance_id in _active_abilities:
|
||||
var abilities = _active_abilities[instance_id]
|
||||
for ability_data in abilities:
|
||||
var ability = ability_data.ability
|
||||
var source = ability_data.source
|
||||
var parsed = ability.get("parsed", {})
|
||||
|
||||
for effect in parsed.get("effects", []):
|
||||
if effect.get("type") == "MULTI_ATTACK":
|
||||
if _card_matches_effect_target(card, effect, source, game_state):
|
||||
var attack_count = effect.get("attack_count", 1)
|
||||
if attack_count > max_attacks:
|
||||
max_attacks = attack_count
|
||||
|
||||
return max_attacks
|
||||
174
scripts/game/abilities/TargetSelector.gd
Normal file
174
scripts/game/abilities/TargetSelector.gd
Normal file
@@ -0,0 +1,174 @@
|
||||
class_name TargetSelector
|
||||
extends RefCounted
|
||||
|
||||
## TargetSelector - Validates and provides target options for effects
|
||||
|
||||
|
||||
## Get all valid targets for an effect's target specification
|
||||
func get_valid_targets(
|
||||
target_spec: Dictionary,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
if target_spec.is_empty():
|
||||
return []
|
||||
|
||||
var candidates: Array = []
|
||||
|
||||
var zone = str(target_spec.get("zone", "FIELD")).to_upper()
|
||||
var owner = str(target_spec.get("owner", "ANY")).to_upper()
|
||||
var filter = target_spec.get("filter", {})
|
||||
var target_type = str(target_spec.get("type", "CHOOSE")).to_upper()
|
||||
|
||||
# Handle SELF and ALL targets specially
|
||||
if target_type == "SELF":
|
||||
return [source]
|
||||
elif target_type == "ALL":
|
||||
return _get_all_matching(owner, zone, filter, source, game_state)
|
||||
|
||||
# Collect candidates from appropriate zones
|
||||
match zone:
|
||||
"FIELD":
|
||||
candidates = _get_field_cards(owner, source, game_state)
|
||||
"HAND":
|
||||
candidates = _get_hand_cards(owner, source, game_state)
|
||||
"BREAK_ZONE", "BREAK":
|
||||
candidates = _get_break_zone_cards(owner, source, game_state)
|
||||
"DECK":
|
||||
candidates = _get_deck_cards(owner, source, game_state)
|
||||
_:
|
||||
# Default to field
|
||||
candidates = _get_field_cards(owner, source, game_state)
|
||||
|
||||
# Apply filters using CardFilter utility
|
||||
return CardFilter.get_matching(candidates, filter, source)
|
||||
|
||||
|
||||
## Get all cards matching filter (for "ALL" target type)
|
||||
func _get_all_matching(
|
||||
owner: String,
|
||||
zone: String,
|
||||
filter: Dictionary,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
var candidates = _get_field_cards(owner, source, game_state)
|
||||
return CardFilter.get_matching(candidates, filter, source)
|
||||
|
||||
|
||||
## Get cards from field
|
||||
func _get_field_cards(
|
||||
owner: String,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
var cards: Array = []
|
||||
|
||||
match owner:
|
||||
"CONTROLLER":
|
||||
var player = game_state.get_player(source.controller_index)
|
||||
if player:
|
||||
cards.append_array(_get_player_field_cards(player))
|
||||
"OPPONENT":
|
||||
var opponent = game_state.get_player(1 - source.controller_index)
|
||||
if opponent:
|
||||
cards.append_array(_get_player_field_cards(opponent))
|
||||
"ANY", _:
|
||||
for player in game_state.players:
|
||||
cards.append_array(_get_player_field_cards(player))
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
## Get all field cards for a player
|
||||
func _get_player_field_cards(player) -> Array:
|
||||
var cards: Array = []
|
||||
cards.append_array(player.field_forwards.get_cards())
|
||||
cards.append_array(player.field_backups.get_cards())
|
||||
return cards
|
||||
|
||||
|
||||
## Get cards from hand
|
||||
func _get_hand_cards(
|
||||
owner: String,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
var cards: Array = []
|
||||
var player_index = source.controller_index
|
||||
|
||||
if owner == "OPPONENT":
|
||||
player_index = 1 - player_index
|
||||
|
||||
var player = game_state.get_player(player_index)
|
||||
if player:
|
||||
cards.append_array(player.hand.get_cards())
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
## Get cards from break zone
|
||||
func _get_break_zone_cards(
|
||||
owner: String,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
var cards: Array = []
|
||||
var player_index = source.controller_index
|
||||
|
||||
if owner == "OPPONENT":
|
||||
player_index = 1 - player_index
|
||||
|
||||
var player = game_state.get_player(player_index)
|
||||
if player:
|
||||
cards.append_array(player.break_zone.get_cards())
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
## Get cards from deck
|
||||
func _get_deck_cards(
|
||||
owner: String,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> Array:
|
||||
# Usually not directly targetable, used for search effects
|
||||
var cards: Array = []
|
||||
var player_index = source.controller_index
|
||||
|
||||
if owner == "OPPONENT":
|
||||
player_index = 1 - player_index
|
||||
|
||||
var player = game_state.get_player(player_index)
|
||||
if player:
|
||||
cards.append_array(player.deck.get_cards())
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
## Validate that a set of targets meets the target specification requirements
|
||||
func validate_targets(
|
||||
targets: Array,
|
||||
target_spec: Dictionary,
|
||||
source: CardInstance,
|
||||
game_state
|
||||
) -> bool:
|
||||
var target_type = str(target_spec.get("type", "CHOOSE")).to_upper()
|
||||
|
||||
# Check count requirements
|
||||
if target_spec.has("count"):
|
||||
var required = int(target_spec.count)
|
||||
if targets.size() != required:
|
||||
return false
|
||||
elif target_spec.has("count_up_to"):
|
||||
var max_count = int(target_spec.count_up_to)
|
||||
if targets.size() > max_count:
|
||||
return false
|
||||
|
||||
# Validate each target is valid
|
||||
var valid_targets = get_valid_targets(target_spec, source, game_state)
|
||||
for target in targets:
|
||||
if target not in valid_targets:
|
||||
return false
|
||||
|
||||
return true
|
||||
233
scripts/game/abilities/TriggerMatcher.gd
Normal file
233
scripts/game/abilities/TriggerMatcher.gd
Normal file
@@ -0,0 +1,233 @@
|
||||
class_name TriggerMatcher
|
||||
extends RefCounted
|
||||
|
||||
## TriggerMatcher - Matches game events to ability triggers
|
||||
## Scans all cards on field for abilities that trigger from the given event
|
||||
|
||||
## Reference to ConditionChecker for evaluating trigger conditions
|
||||
var condition_checker: ConditionChecker = null
|
||||
|
||||
|
||||
## Find all abilities that should trigger for a given event
|
||||
func find_triggered_abilities(
|
||||
event_type: String,
|
||||
event_data: Dictionary,
|
||||
game_state,
|
||||
all_abilities: Dictionary
|
||||
) -> Array:
|
||||
var triggered = []
|
||||
|
||||
# Check abilities on all cards in play
|
||||
for player in game_state.players:
|
||||
# Check forwards
|
||||
for card in player.field_forwards.get_cards():
|
||||
var card_abilities = all_abilities.get(card.card_data.id, [])
|
||||
triggered.append_array(_check_card_abilities(card, card_abilities, event_type, event_data, game_state))
|
||||
|
||||
# Check backups
|
||||
for card in player.field_backups.get_cards():
|
||||
var card_abilities = all_abilities.get(card.card_data.id, [])
|
||||
triggered.append_array(_check_card_abilities(card, card_abilities, event_type, event_data, game_state))
|
||||
|
||||
return triggered
|
||||
|
||||
|
||||
## Check all abilities on a card for triggers
|
||||
func _check_card_abilities(
|
||||
card: CardInstance,
|
||||
abilities: Array,
|
||||
event_type: String,
|
||||
event_data: Dictionary,
|
||||
game_state
|
||||
) -> Array:
|
||||
var triggered = []
|
||||
|
||||
for ability in abilities:
|
||||
if _matches_trigger(ability, event_type, event_data, card, game_state):
|
||||
triggered.append({
|
||||
"source": card,
|
||||
"ability": ability,
|
||||
"event_data": event_data
|
||||
})
|
||||
|
||||
return triggered
|
||||
|
||||
|
||||
## Check if an ability's trigger matches the event
|
||||
func _matches_trigger(
|
||||
ability: Dictionary,
|
||||
event_type: String,
|
||||
event_data: Dictionary,
|
||||
source_card: CardInstance,
|
||||
game_state
|
||||
) -> bool:
|
||||
var parsed = ability.get("parsed", {})
|
||||
if parsed.is_empty():
|
||||
return false
|
||||
|
||||
# Only AUTO abilities have triggers
|
||||
if parsed.get("type") != "AUTO":
|
||||
return false
|
||||
|
||||
var trigger = parsed.get("trigger", {})
|
||||
if trigger.is_empty():
|
||||
return false
|
||||
|
||||
# Check event type matches
|
||||
var trigger_event = trigger.get("event", "")
|
||||
if not _event_matches(trigger_event, event_type):
|
||||
return false
|
||||
|
||||
# Check source filter
|
||||
var trigger_source = trigger.get("source", "ANY")
|
||||
if not _source_matches(trigger_source, event_data, source_card, game_state):
|
||||
return false
|
||||
|
||||
# Check additional trigger filters
|
||||
if trigger.has("source_filter"):
|
||||
var filter = trigger.source_filter
|
||||
var event_card = event_data.get("card")
|
||||
if event_card and not _matches_card_filter(event_card, filter):
|
||||
return false
|
||||
|
||||
# Check trigger condition (if present)
|
||||
var trigger_condition = trigger.get("condition", {})
|
||||
if not trigger_condition.is_empty() and condition_checker:
|
||||
var context = {
|
||||
"source_card": source_card,
|
||||
"target_card": event_data.get("card"),
|
||||
"game_state": game_state,
|
||||
"player_id": source_card.controller_index if source_card else 0,
|
||||
"event_data": event_data
|
||||
}
|
||||
if not condition_checker.evaluate(trigger_condition, context):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Check if event type matches trigger event
|
||||
func _event_matches(trigger_event: String, actual_event: String) -> bool:
|
||||
# Direct match
|
||||
if trigger_event == actual_event:
|
||||
return true
|
||||
|
||||
# Handle variations
|
||||
match trigger_event:
|
||||
"ENTERS_FIELD":
|
||||
return actual_event in ["ENTERS_FIELD", "CARD_PLAYED"]
|
||||
"LEAVES_FIELD":
|
||||
return actual_event in ["LEAVES_FIELD", "FORWARD_BROKEN", "CARD_BROKEN"]
|
||||
"DEALS_DAMAGE":
|
||||
return actual_event in ["DEALS_DAMAGE", "DEALS_DAMAGE_TO_OPPONENT", "DEALS_DAMAGE_TO_FORWARD"]
|
||||
"DEALS_DAMAGE_TO_OPPONENT":
|
||||
return actual_event == "DEALS_DAMAGE_TO_OPPONENT"
|
||||
"DEALS_DAMAGE_TO_FORWARD":
|
||||
return actual_event == "DEALS_DAMAGE_TO_FORWARD"
|
||||
"BLOCKS_OR_IS_BLOCKED":
|
||||
return actual_event in ["BLOCKS", "IS_BLOCKED"]
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Check if source matches trigger requirements
|
||||
func _source_matches(
|
||||
trigger_source: String,
|
||||
event_data: Dictionary,
|
||||
source_card: CardInstance,
|
||||
game_state
|
||||
) -> bool:
|
||||
var event_card = event_data.get("card")
|
||||
|
||||
match trigger_source:
|
||||
"SELF":
|
||||
# Trigger source must be this card
|
||||
return event_card == source_card
|
||||
"CONTROLLER":
|
||||
# Trigger source must be controlled by same player
|
||||
if event_card:
|
||||
return event_card.controller_index == source_card.controller_index
|
||||
var event_player = event_data.get("player", -1)
|
||||
return event_player == source_card.controller_index
|
||||
"OPPONENT":
|
||||
# Trigger source must be controlled by opponent
|
||||
if event_card:
|
||||
return event_card.controller_index != source_card.controller_index
|
||||
var event_player = event_data.get("player", -1)
|
||||
return event_player != source_card.controller_index and event_player >= 0
|
||||
"ANY", _:
|
||||
# Any source triggers
|
||||
return true
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Check if a card matches a filter
|
||||
func _matches_card_filter(card: CardInstance, filter: Dictionary) -> bool:
|
||||
if filter.is_empty():
|
||||
return true
|
||||
|
||||
# Card type filter
|
||||
if filter.has("card_type"):
|
||||
var type_str = str(filter.card_type).to_upper()
|
||||
match type_str:
|
||||
"FORWARD":
|
||||
if not card.is_forward():
|
||||
return false
|
||||
"BACKUP":
|
||||
if not card.is_backup():
|
||||
return false
|
||||
"SUMMON":
|
||||
if not card.is_summon():
|
||||
return false
|
||||
|
||||
# Element filter
|
||||
if filter.has("element"):
|
||||
var element_str = str(filter.element).to_upper()
|
||||
var element = Enums.element_from_string(element_str)
|
||||
if element not in card.get_elements():
|
||||
return false
|
||||
|
||||
# Cost filters
|
||||
if filter.has("cost_min"):
|
||||
if card.card_data.cost < filter.cost_min:
|
||||
return false
|
||||
if filter.has("cost_max"):
|
||||
if card.card_data.cost > filter.cost_max:
|
||||
return false
|
||||
if filter.has("cost"):
|
||||
if card.card_data.cost != filter.cost:
|
||||
return false
|
||||
|
||||
# Power filters
|
||||
if filter.has("power_min"):
|
||||
if card.get_power() < filter.power_min:
|
||||
return false
|
||||
if filter.has("power_max"):
|
||||
if card.get_power() > filter.power_max:
|
||||
return false
|
||||
|
||||
# State filters
|
||||
if filter.has("is_dull"):
|
||||
if card.is_dull() != filter.is_dull:
|
||||
return false
|
||||
if filter.has("is_active"):
|
||||
if card.is_active() != filter.is_active:
|
||||
return false
|
||||
|
||||
# Name filter
|
||||
if filter.has("name"):
|
||||
if card.card_data.name != filter.name:
|
||||
return false
|
||||
|
||||
# Category filter
|
||||
if filter.has("category"):
|
||||
if card.card_data.category != filter.category:
|
||||
return false
|
||||
|
||||
# Job filter
|
||||
if filter.has("job"):
|
||||
if card.card_data.job != filter.job:
|
||||
return false
|
||||
|
||||
return true
|
||||
223
scripts/game/ai/AIController.gd
Normal file
223
scripts/game/ai/AIController.gd
Normal file
@@ -0,0 +1,223 @@
|
||||
class_name AIController
|
||||
extends Node
|
||||
|
||||
## AIController - Coordinates AI player turns
|
||||
## Handles timing, action execution, and phase transitions
|
||||
|
||||
signal ai_action_started
|
||||
signal ai_action_completed
|
||||
signal ai_thinking(player_index: int)
|
||||
|
||||
var strategy: AIStrategy
|
||||
var game_state: GameState
|
||||
var player_index: int
|
||||
var is_processing: bool = false
|
||||
|
||||
# Reference to GameManager for executing actions
|
||||
var _game_manager: Node
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func setup(p_player_index: int, difficulty: AIStrategy.Difficulty, p_game_manager: Node) -> void:
|
||||
player_index = p_player_index
|
||||
_game_manager = p_game_manager
|
||||
|
||||
# Create appropriate strategy based on difficulty
|
||||
match difficulty:
|
||||
AIStrategy.Difficulty.EASY:
|
||||
strategy = EasyAI.new(player_index)
|
||||
AIStrategy.Difficulty.NORMAL:
|
||||
strategy = NormalAI.new(player_index)
|
||||
AIStrategy.Difficulty.HARD:
|
||||
strategy = HardAI.new(player_index)
|
||||
|
||||
|
||||
func set_game_state(state: GameState) -> void:
|
||||
game_state = state
|
||||
if strategy:
|
||||
strategy.set_game_state(state)
|
||||
|
||||
|
||||
## Called when it's the AI's turn to act in the current phase
|
||||
func process_turn() -> void:
|
||||
if is_processing:
|
||||
return
|
||||
|
||||
is_processing = true
|
||||
ai_thinking.emit(player_index)
|
||||
|
||||
# Add thinking delay
|
||||
var delay := strategy.get_thinking_delay()
|
||||
await get_tree().create_timer(delay).timeout
|
||||
|
||||
var phase := game_state.turn_manager.current_phase
|
||||
|
||||
match phase:
|
||||
Enums.TurnPhase.ACTIVE:
|
||||
# Active phase is automatic - no AI decision needed
|
||||
_pass_priority()
|
||||
|
||||
Enums.TurnPhase.DRAW:
|
||||
# Draw phase is automatic
|
||||
_pass_priority()
|
||||
|
||||
Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2:
|
||||
await _process_main_phase()
|
||||
|
||||
Enums.TurnPhase.ATTACK:
|
||||
await _process_attack_phase()
|
||||
|
||||
Enums.TurnPhase.END:
|
||||
# End phase is automatic
|
||||
_pass_priority()
|
||||
|
||||
is_processing = false
|
||||
ai_action_completed.emit()
|
||||
|
||||
|
||||
## Process main phase - play cards or pass
|
||||
func _process_main_phase() -> void:
|
||||
var max_actions := 10 # Prevent infinite loops
|
||||
var actions_taken := 0
|
||||
|
||||
while actions_taken < max_actions:
|
||||
var decision := strategy.decide_main_phase_action()
|
||||
|
||||
if decision.action == "pass":
|
||||
_pass_priority()
|
||||
break
|
||||
|
||||
elif decision.action == "play":
|
||||
var card: CardInstance = decision.card
|
||||
var success := await _try_play_card(card)
|
||||
|
||||
if not success:
|
||||
# Couldn't play - pass
|
||||
_pass_priority()
|
||||
break
|
||||
|
||||
# Small delay between actions
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
|
||||
actions_taken += 1
|
||||
|
||||
|
||||
## Try to play a card, handling CP generation if needed
|
||||
func _try_play_card(card: CardInstance) -> bool:
|
||||
var player := game_state.get_player(player_index)
|
||||
var cost := card.card_data.cost
|
||||
|
||||
# Check if we have enough CP
|
||||
var current_cp := player.cp_pool.get_total_cp()
|
||||
|
||||
if current_cp < cost:
|
||||
# Need to generate CP
|
||||
var needed := cost - current_cp
|
||||
var success := await _generate_cp(needed, card.card_data.elements)
|
||||
if not success:
|
||||
return false
|
||||
|
||||
# Try to play the card
|
||||
return _game_manager.try_play_card(card)
|
||||
|
||||
|
||||
## Generate CP by dulling backups or discarding cards
|
||||
func _generate_cp(needed: int, elements: Array) -> bool:
|
||||
var generated := 0
|
||||
var max_attempts := 20
|
||||
|
||||
while generated < needed and max_attempts > 0:
|
||||
var decision := strategy.decide_cp_generation({ "needed": needed - generated, "elements": elements })
|
||||
|
||||
if decision.is_empty():
|
||||
return false
|
||||
|
||||
if decision.action == "dull_backup":
|
||||
var backup: CardInstance = decision.card
|
||||
if _game_manager.dull_backup_for_cp(backup):
|
||||
generated += 1
|
||||
await get_tree().create_timer(0.2).timeout
|
||||
|
||||
elif decision.action == "discard":
|
||||
var discard_card: CardInstance = decision.card
|
||||
if _game_manager.discard_card_for_cp(discard_card):
|
||||
generated += 2
|
||||
await get_tree().create_timer(0.2).timeout
|
||||
|
||||
max_attempts -= 1
|
||||
|
||||
return generated >= needed
|
||||
|
||||
|
||||
## Process attack phase - declare attacks
|
||||
func _process_attack_phase() -> void:
|
||||
var attack_step := game_state.turn_manager.attack_step
|
||||
|
||||
match attack_step:
|
||||
Enums.AttackStep.PREPARATION, Enums.AttackStep.DECLARATION:
|
||||
await _process_attack_declaration()
|
||||
|
||||
Enums.AttackStep.BLOCK_DECLARATION:
|
||||
# This shouldn't happen - AI blocks are handled in opponent's turn
|
||||
_pass_priority()
|
||||
|
||||
Enums.AttackStep.DAMAGE_RESOLUTION:
|
||||
# Automatic
|
||||
_pass_priority()
|
||||
|
||||
|
||||
## Declare attacks with forwards
|
||||
func _process_attack_declaration() -> void:
|
||||
var max_attacks := 5
|
||||
var attacks_made := 0
|
||||
|
||||
while attacks_made < max_attacks:
|
||||
var decision := strategy.decide_attack_action()
|
||||
|
||||
if decision.action == "end_attacks":
|
||||
# End attack phase
|
||||
_game_manager.pass_priority()
|
||||
break
|
||||
|
||||
elif decision.action == "attack":
|
||||
var attacker: CardInstance = decision.card
|
||||
var success := _game_manager.declare_attack(attacker)
|
||||
|
||||
if success:
|
||||
attacks_made += 1
|
||||
# Wait for block decision or damage resolution
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
else:
|
||||
# Couldn't attack - end attacks
|
||||
_game_manager.pass_priority()
|
||||
break
|
||||
|
||||
|
||||
## Called when AI needs to decide on blocking
|
||||
func process_block_decision(attacker: CardInstance) -> void:
|
||||
if is_processing:
|
||||
return
|
||||
|
||||
is_processing = true
|
||||
ai_thinking.emit(player_index)
|
||||
|
||||
var delay := strategy.get_thinking_delay()
|
||||
await get_tree().create_timer(delay).timeout
|
||||
|
||||
var decision := strategy.decide_block_action(attacker)
|
||||
|
||||
if decision.action == "block":
|
||||
var blocker: CardInstance = decision.card
|
||||
_game_manager.declare_block(blocker)
|
||||
else:
|
||||
_game_manager.skip_block()
|
||||
|
||||
is_processing = false
|
||||
ai_action_completed.emit()
|
||||
|
||||
|
||||
func _pass_priority() -> void:
|
||||
_game_manager.pass_priority()
|
||||
190
scripts/game/ai/AIStrategy.gd
Normal file
190
scripts/game/ai/AIStrategy.gd
Normal file
@@ -0,0 +1,190 @@
|
||||
class_name AIStrategy
|
||||
extends RefCounted
|
||||
|
||||
## Base class for AI decision-making strategies
|
||||
## Subclasses implement different difficulty levels
|
||||
|
||||
enum Difficulty { EASY, NORMAL, HARD }
|
||||
|
||||
var difficulty: Difficulty
|
||||
var player_index: int
|
||||
var game_state: GameState
|
||||
|
||||
|
||||
func _init(p_difficulty: Difficulty, p_player_index: int) -> void:
|
||||
difficulty = p_difficulty
|
||||
player_index = p_player_index
|
||||
|
||||
|
||||
func set_game_state(state: GameState) -> void:
|
||||
game_state = state
|
||||
|
||||
|
||||
## Returns the player this AI controls
|
||||
func get_player() -> Player:
|
||||
return game_state.get_player(player_index)
|
||||
|
||||
|
||||
## Returns the opponent player
|
||||
func get_opponent() -> Player:
|
||||
return game_state.get_player(1 - player_index)
|
||||
|
||||
|
||||
## Called during Main Phase - decide what card to play or pass
|
||||
## Returns: { "action": "play", "card": CardInstance } or { "action": "pass" }
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
push_error("AIStrategy.decide_main_phase_action() must be overridden")
|
||||
return { "action": "pass" }
|
||||
|
||||
|
||||
## Called when CP is needed - decide how to generate CP
|
||||
## Returns: { "action": "discard", "card": CardInstance } or { "action": "dull_backup", "card": CardInstance }
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
push_error("AIStrategy.decide_cp_generation() must be overridden")
|
||||
return {}
|
||||
|
||||
|
||||
## Called during Attack Phase - decide which forward to attack with
|
||||
## Returns: { "action": "attack", "card": CardInstance } or { "action": "end_attacks" }
|
||||
func decide_attack_action() -> Dictionary:
|
||||
push_error("AIStrategy.decide_attack_action() must be overridden")
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
|
||||
## Called during Block Declaration - decide how to block
|
||||
## Returns: { "action": "block", "card": CardInstance } or { "action": "skip" }
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
push_error("AIStrategy.decide_block_action() must be overridden")
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
## Get thinking delay range in seconds based on difficulty
|
||||
func get_thinking_delay() -> float:
|
||||
match difficulty:
|
||||
Difficulty.EASY:
|
||||
return randf_range(1.5, 2.5)
|
||||
Difficulty.NORMAL:
|
||||
return randf_range(1.0, 1.5)
|
||||
Difficulty.HARD:
|
||||
return randf_range(0.5, 1.0)
|
||||
return 1.0
|
||||
|
||||
|
||||
# ============ HELPER METHODS FOR SUBCLASSES ============
|
||||
|
||||
## Get all cards in hand that can be played (have enough CP or can generate CP)
|
||||
func get_playable_cards() -> Array[CardInstance]:
|
||||
var player := get_player()
|
||||
var playable: Array[CardInstance] = []
|
||||
|
||||
for card in player.hand.get_cards():
|
||||
if _can_afford_card(card):
|
||||
playable.append(card)
|
||||
|
||||
return playable
|
||||
|
||||
|
||||
## Check if a card can be afforded (either have CP or can generate it)
|
||||
func _can_afford_card(card: CardInstance) -> bool:
|
||||
var player := get_player()
|
||||
var cost := card.card_data.cost
|
||||
var elements := card.card_data.elements
|
||||
|
||||
# Check if we already have enough CP
|
||||
var current_cp := player.cp_pool.get_total_cp()
|
||||
if current_cp >= cost:
|
||||
# Check element requirements
|
||||
for element in elements:
|
||||
if player.cp_pool.get_cp(element) > 0 or player.cp_pool.get_cp(Enums.Element.NONE) > 0:
|
||||
return true
|
||||
# If no specific element needed (Light/Dark cards), any CP works
|
||||
if elements.is_empty():
|
||||
return true
|
||||
|
||||
# Check if we can generate enough CP
|
||||
var potential_cp := _calculate_potential_cp()
|
||||
return potential_cp >= cost
|
||||
|
||||
|
||||
## Calculate total CP we could generate (hand discards + backup dulls)
|
||||
func _calculate_potential_cp() -> int:
|
||||
var player := get_player()
|
||||
var total := player.cp_pool.get_total_cp()
|
||||
|
||||
# Each card in hand can be discarded for 2 CP
|
||||
total += player.hand.get_card_count() * 2
|
||||
|
||||
# Each active backup can be dulled for 1 CP
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
total += 1
|
||||
|
||||
return total
|
||||
|
||||
|
||||
## Get forwards that can attack
|
||||
func get_attackable_forwards() -> Array[CardInstance]:
|
||||
return get_player().get_attackable_forwards()
|
||||
|
||||
|
||||
## Get forwards that can block
|
||||
func get_blockable_forwards() -> Array[CardInstance]:
|
||||
return get_player().get_blockable_forwards()
|
||||
|
||||
|
||||
## Calculate a simple card value score
|
||||
func calculate_card_value(card: CardInstance) -> float:
|
||||
var data := card.card_data
|
||||
var value := 0.0
|
||||
|
||||
match data.type:
|
||||
Enums.CardType.FORWARD:
|
||||
# Forwards valued by power/cost ratio + abilities
|
||||
value = float(data.power) / float(max(data.cost, 1))
|
||||
if data.has_ability("Brave"):
|
||||
value *= 1.3
|
||||
if data.has_ability("First Strike"):
|
||||
value *= 1.2
|
||||
if data.has_ability("Haste"):
|
||||
value *= 1.4
|
||||
Enums.CardType.BACKUP:
|
||||
# Backups valued by utility (cost efficiency)
|
||||
value = 3.0 / float(max(data.cost, 1))
|
||||
Enums.CardType.SUMMON:
|
||||
# Summons valued by effect strength (approximated by cost)
|
||||
value = float(data.cost) * 0.8
|
||||
Enums.CardType.MONSTER:
|
||||
# Monsters similar to forwards
|
||||
value = float(data.power) / float(max(data.cost, 1))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
## Evaluate board advantage (positive = we're ahead)
|
||||
func evaluate_board_state() -> float:
|
||||
var player := get_player()
|
||||
var opponent := get_opponent()
|
||||
|
||||
var score := 0.0
|
||||
|
||||
# Forward power advantage
|
||||
var our_power := 0
|
||||
for forward in player.field_forwards.get_cards():
|
||||
our_power += forward.get_power()
|
||||
|
||||
var their_power := 0
|
||||
for forward in opponent.field_forwards.get_cards():
|
||||
their_power += forward.get_power()
|
||||
|
||||
score += (our_power - their_power) / 1000.0
|
||||
|
||||
# Backup count advantage
|
||||
score += (player.field_backups.get_card_count() - opponent.field_backups.get_card_count()) * 2.0
|
||||
|
||||
# Hand size advantage
|
||||
score += (player.hand.get_card_count() - opponent.hand.get_card_count()) * 0.5
|
||||
|
||||
# Damage disadvantage (more damage = worse)
|
||||
score -= (player.get_damage_count() - opponent.get_damage_count()) * 3.0
|
||||
|
||||
return score
|
||||
71
scripts/game/ai/EasyAI.gd
Normal file
71
scripts/game/ai/EasyAI.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
class_name EasyAI
|
||||
extends AIStrategy
|
||||
|
||||
## Easy AI - Makes suboptimal choices, sometimes skips good plays
|
||||
## Good for beginners learning the game
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.EASY, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
# 30% chance to just pass even if we have playable cards
|
||||
if randf() < 0.3:
|
||||
return { "action": "pass" }
|
||||
|
||||
# Pick a random playable card (not optimal)
|
||||
var card: CardInstance = playable[randi() % playable.size()]
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# Prefer dulling backups first (Easy AI doesn't optimize)
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
return { "action": "dull_backup", "card": backup }
|
||||
|
||||
# Discard a random card from hand
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if not hand_cards.is_empty():
|
||||
var card: CardInstance = hand_cards[randi() % hand_cards.size()]
|
||||
return { "action": "discard", "card": card }
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# 40% chance to not attack even if we can
|
||||
if randf() < 0.4:
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Pick a random attacker
|
||||
var attacker: CardInstance = attackers[randi() % attackers.size()]
|
||||
return { "action": "attack", "card": attacker }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
# 50% chance to skip blocking even when possible
|
||||
if randf() < 0.5:
|
||||
return { "action": "skip" }
|
||||
|
||||
# Pick a random blocker (might not be optimal)
|
||||
var blocker: CardInstance = blockers[randi() % blockers.size()]
|
||||
return { "action": "block", "card": blocker }
|
||||
271
scripts/game/ai/HardAI.gd
Normal file
271
scripts/game/ai/HardAI.gd
Normal file
@@ -0,0 +1,271 @@
|
||||
class_name HardAI
|
||||
extends AIStrategy
|
||||
|
||||
## Hard AI - Optimal rule-based decisions with full board analysis
|
||||
## Considers multiple factors and makes the best available play
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.HARD, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
var board_eval := evaluate_board_state()
|
||||
var player := get_player()
|
||||
var opponent := get_opponent()
|
||||
|
||||
# Analyze the best play considering multiple factors
|
||||
var best_card: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for card in playable:
|
||||
var score := _evaluate_play(card, board_eval, player, opponent)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_card = card
|
||||
|
||||
# Only play if the score is positive
|
||||
if best_score > 0 and best_card:
|
||||
return { "action": "play", "card": best_card }
|
||||
|
||||
return { "action": "pass" }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# Prioritize dulling backups (they refresh next turn, no card loss)
|
||||
var backups_to_dull: Array[CardInstance] = []
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
backups_to_dull.append(backup)
|
||||
|
||||
if not backups_to_dull.is_empty():
|
||||
# Dull backups with least useful abilities first
|
||||
backups_to_dull.sort_custom(_compare_backup_utility)
|
||||
return { "action": "dull_backup", "card": backups_to_dull[0] }
|
||||
|
||||
# Discard cards - choose most expendable
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if hand_cards.is_empty():
|
||||
return {}
|
||||
|
||||
# Evaluate each card for discard value
|
||||
var best_discard: CardInstance = null
|
||||
var lowest_value := 999.0
|
||||
|
||||
for card in hand_cards:
|
||||
var value := _evaluate_discard_value(card, player)
|
||||
if value < lowest_value:
|
||||
lowest_value = value
|
||||
best_discard = card
|
||||
|
||||
if best_discard:
|
||||
return { "action": "discard", "card": best_discard }
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
var opponent := get_opponent()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
var opponent_blockers := opponent.get_blockable_forwards()
|
||||
|
||||
# Calculate optimal attack order
|
||||
var attack_order := _calculate_attack_order(attackers, opponent_blockers, opponent)
|
||||
|
||||
if attack_order.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Return the best attack
|
||||
return { "action": "attack", "card": attack_order[0] }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
var player := get_player()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
var attacker_power := attacker.get_power()
|
||||
var current_damage := player.get_damage_count()
|
||||
var would_be_lethal := current_damage >= 6
|
||||
|
||||
# Evaluate all blocking options
|
||||
var best_blocker: CardInstance = null
|
||||
var best_score := 0.0 # Baseline: skip blocking (score 0)
|
||||
|
||||
for blocker in blockers:
|
||||
var score := _evaluate_block(blocker, attacker, would_be_lethal)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_blocker = blocker
|
||||
|
||||
if best_blocker:
|
||||
return { "action": "block", "card": best_blocker }
|
||||
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
func _evaluate_play(card: CardInstance, board_eval: float, player: Player, opponent: Player) -> float:
|
||||
var data := card.card_data
|
||||
var score := calculate_card_value(card)
|
||||
|
||||
match data.type:
|
||||
Enums.CardType.FORWARD:
|
||||
# Forwards more valuable when behind on board
|
||||
if board_eval < 0:
|
||||
score *= 1.5
|
||||
|
||||
# Extra value if opponent has no blockers
|
||||
if opponent.get_blockable_forwards().is_empty():
|
||||
score *= 1.3
|
||||
|
||||
# Consider if we already have 5 forwards (max)
|
||||
if player.field_forwards.get_card_count() >= 5:
|
||||
score *= 0.3
|
||||
|
||||
Enums.CardType.BACKUP:
|
||||
# Backups valuable for long game
|
||||
var backup_count := player.field_backups.get_card_count()
|
||||
if backup_count >= 5:
|
||||
score = -10.0 # Can't play more
|
||||
elif backup_count < 3:
|
||||
score *= 1.5 # Need more backups
|
||||
elif board_eval > 5:
|
||||
score *= 1.3 # Ahead, build infrastructure
|
||||
|
||||
Enums.CardType.SUMMON:
|
||||
# Summons are situational - evaluate based on current needs
|
||||
# This is simplified; real evaluation would check summon effects
|
||||
if board_eval < -3:
|
||||
score *= 1.4 # Need removal/utility when behind
|
||||
|
||||
Enums.CardType.MONSTER:
|
||||
# Similar to forwards but usually less efficient
|
||||
score *= 0.9
|
||||
|
||||
# Penalize expensive plays when low on cards
|
||||
if player.hand.get_card_count() <= 2 and data.cost >= 4:
|
||||
score *= 0.5
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _evaluate_discard_value(card: CardInstance, player: Player) -> float:
|
||||
var value := calculate_card_value(card)
|
||||
|
||||
# Duplicates in hand are less valuable
|
||||
var same_name_count := 0
|
||||
for hand_card in player.hand.get_cards():
|
||||
if hand_card.card_data.name == card.card_data.name:
|
||||
same_name_count += 1
|
||||
if same_name_count > 1:
|
||||
value *= 0.5
|
||||
|
||||
# High cost cards we can't afford soon are less valuable
|
||||
var potential_cp := _calculate_potential_cp()
|
||||
if card.card_data.cost > potential_cp:
|
||||
value *= 0.7
|
||||
|
||||
# Cards matching elements we don't have CP for are less valuable
|
||||
var has_element_match := false
|
||||
for element in card.card_data.elements:
|
||||
if player.cp_pool.get_cp(element) > 0:
|
||||
has_element_match = true
|
||||
break
|
||||
if not has_element_match and not card.card_data.elements.is_empty():
|
||||
value *= 0.8
|
||||
|
||||
return value
|
||||
|
||||
|
||||
func _calculate_attack_order(attackers: Array[CardInstance], blockers: Array[CardInstance], opponent: Player) -> Array[CardInstance]:
|
||||
var order: Array[CardInstance] = []
|
||||
var scores: Array[Dictionary] = []
|
||||
|
||||
for attacker in attackers:
|
||||
var score := _evaluate_attack_value(attacker, blockers, opponent)
|
||||
if score > 0:
|
||||
scores.append({ "card": attacker, "score": score })
|
||||
|
||||
# Sort by score descending
|
||||
scores.sort_custom(func(a, b): return a.score > b.score)
|
||||
|
||||
for entry in scores:
|
||||
order.append(entry.card)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
func _evaluate_attack_value(attacker: CardInstance, blockers: Array[CardInstance], opponent: Player) -> float:
|
||||
var score := 0.0
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Base value for dealing damage
|
||||
score += 3.0
|
||||
|
||||
# Lethal damage is extremely valuable
|
||||
if opponent.get_damage_count() >= 6:
|
||||
score += 20.0
|
||||
|
||||
# Evaluate blocking scenarios
|
||||
var profitable_blocks := 0
|
||||
for blocker in blockers:
|
||||
var blocker_power := blocker.get_power()
|
||||
if blocker_power >= attacker_power:
|
||||
profitable_blocks += 1
|
||||
|
||||
if profitable_blocks == 0:
|
||||
# No profitable blocks - guaranteed damage
|
||||
score += 5.0
|
||||
else:
|
||||
# Risk of losing our forward
|
||||
score -= calculate_card_value(attacker) * 0.5
|
||||
|
||||
# Brave forwards can attack safely (don't dull)
|
||||
if attacker.card_data.has_ability("Brave"):
|
||||
score += 2.0
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _evaluate_block(blocker: CardInstance, attacker: CardInstance, would_be_lethal: bool) -> float:
|
||||
var blocker_power := blocker.get_power()
|
||||
var attacker_power := attacker.get_power()
|
||||
var score := 0.0
|
||||
|
||||
# If lethal, blocking is almost always correct
|
||||
if would_be_lethal:
|
||||
score += 15.0
|
||||
|
||||
# Do we kill the attacker?
|
||||
if blocker_power >= attacker_power:
|
||||
score += calculate_card_value(attacker)
|
||||
|
||||
# Do we lose our blocker?
|
||||
if attacker_power >= blocker_power:
|
||||
score -= calculate_card_value(blocker)
|
||||
|
||||
# First strike changes the calculation
|
||||
if blocker.card_data.has_ability("First Strike") and blocker_power >= attacker_power:
|
||||
# We kill them before they hit us
|
||||
score += calculate_card_value(blocker) * 0.5
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _compare_backup_utility(a: CardInstance, b: CardInstance) -> bool:
|
||||
# Lower utility = dull first
|
||||
# This is simplified; could check specific backup abilities
|
||||
return calculate_card_value(a) < calculate_card_value(b)
|
||||
161
scripts/game/ai/NormalAI.gd
Normal file
161
scripts/game/ai/NormalAI.gd
Normal file
@@ -0,0 +1,161 @@
|
||||
class_name NormalAI
|
||||
extends AIStrategy
|
||||
|
||||
## Normal AI - Balanced play using cost/power heuristics
|
||||
## Makes generally good decisions but doesn't deeply analyze
|
||||
|
||||
|
||||
func _init(p_player_index: int) -> void:
|
||||
super._init(Difficulty.NORMAL, p_player_index)
|
||||
|
||||
|
||||
func decide_main_phase_action() -> Dictionary:
|
||||
var playable := get_playable_cards()
|
||||
|
||||
if playable.is_empty():
|
||||
return { "action": "pass" }
|
||||
|
||||
# Sort by value (best cards first)
|
||||
playable.sort_custom(_compare_card_value)
|
||||
|
||||
# Consider board state - prioritize forwards if we're behind
|
||||
var board_eval := evaluate_board_state()
|
||||
|
||||
for card in playable:
|
||||
var card_type := card.card_data.type
|
||||
|
||||
# If behind on board, prioritize forwards
|
||||
if board_eval < -5.0 and card_type == Enums.CardType.FORWARD:
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
# If ahead, might want backups for sustainability
|
||||
if board_eval > 5.0 and card_type == Enums.CardType.BACKUP:
|
||||
if get_player().field_backups.get_card_count() < 5:
|
||||
return { "action": "play", "card": card }
|
||||
|
||||
# Default: play the highest value card we can afford
|
||||
return { "action": "play", "card": playable[0] }
|
||||
|
||||
|
||||
func decide_cp_generation(needed_cp: Dictionary) -> Dictionary:
|
||||
var player := get_player()
|
||||
|
||||
# First, dull backups (they refresh next turn)
|
||||
for backup in player.field_backups.get_cards():
|
||||
if backup.state == Enums.CardState.ACTIVE:
|
||||
return { "action": "dull_backup", "card": backup }
|
||||
|
||||
# Then, discard lowest value card from hand
|
||||
var hand_cards := player.hand.get_cards()
|
||||
if hand_cards.is_empty():
|
||||
return {}
|
||||
|
||||
# Sort by value (lowest first for discard)
|
||||
var sorted_hand := hand_cards.duplicate()
|
||||
sorted_hand.sort_custom(_compare_card_value_reverse)
|
||||
|
||||
return { "action": "discard", "card": sorted_hand[0] }
|
||||
|
||||
|
||||
func decide_attack_action() -> Dictionary:
|
||||
var attackers := get_attackable_forwards()
|
||||
var opponent := get_opponent()
|
||||
|
||||
if attackers.is_empty():
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
# Get opponent's potential blockers
|
||||
var opponent_blockers := opponent.get_blockable_forwards()
|
||||
|
||||
# Evaluate each potential attacker
|
||||
var best_attacker: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for attacker in attackers:
|
||||
var score := _evaluate_attack(attacker, opponent_blockers, opponent)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_attacker = attacker
|
||||
|
||||
# Only attack if the score is positive (favorable)
|
||||
if best_score > 0 and best_attacker:
|
||||
return { "action": "attack", "card": best_attacker }
|
||||
|
||||
return { "action": "end_attacks" }
|
||||
|
||||
|
||||
func decide_block_action(attacker: CardInstance) -> Dictionary:
|
||||
var blockers := get_blockable_forwards()
|
||||
var player := get_player()
|
||||
|
||||
if blockers.is_empty():
|
||||
return { "action": "skip" }
|
||||
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Check if this attack would be lethal
|
||||
var current_damage := player.get_damage_count()
|
||||
var would_be_lethal := current_damage >= 6 # 7th damage loses
|
||||
|
||||
# Find best blocker
|
||||
var best_blocker: CardInstance = null
|
||||
var best_score := -999.0
|
||||
|
||||
for blocker in blockers:
|
||||
var blocker_power := blocker.get_power()
|
||||
var score := 0.0
|
||||
|
||||
# Would we win the trade?
|
||||
if blocker_power >= attacker_power:
|
||||
score += 5.0 # We kill their forward
|
||||
if attacker_power >= blocker_power:
|
||||
score -= calculate_card_value(blocker) # We lose our blocker
|
||||
|
||||
# If lethal, blocking is very important
|
||||
if would_be_lethal:
|
||||
score += 10.0
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_blocker = blocker
|
||||
|
||||
# Block if favorable or if lethal
|
||||
if best_score > 0 or would_be_lethal:
|
||||
if best_blocker:
|
||||
return { "action": "block", "card": best_blocker }
|
||||
|
||||
return { "action": "skip" }
|
||||
|
||||
|
||||
func _evaluate_attack(attacker: CardInstance, opponent_blockers: Array[CardInstance], opponent: Player) -> float:
|
||||
var score := 0.0
|
||||
var attacker_power := attacker.get_power()
|
||||
|
||||
# Base value: dealing damage is good
|
||||
score += 2.0
|
||||
|
||||
# Check if opponent can block profitably
|
||||
var can_be_blocked := false
|
||||
for blocker in opponent_blockers:
|
||||
if blocker.get_power() >= attacker_power:
|
||||
can_be_blocked = true
|
||||
score -= 3.0 # Likely to lose our forward
|
||||
break
|
||||
|
||||
# If unblockable damage, more valuable
|
||||
if not can_be_blocked:
|
||||
score += 3.0
|
||||
|
||||
# If this would be lethal damage (7th), very valuable
|
||||
if opponent.get_damage_count() >= 6:
|
||||
score += 10.0
|
||||
|
||||
return score
|
||||
|
||||
|
||||
func _compare_card_value(a: CardInstance, b: CardInstance) -> bool:
|
||||
return calculate_card_value(a) > calculate_card_value(b)
|
||||
|
||||
|
||||
func _compare_card_value_reverse(a: CardInstance, b: CardInstance) -> bool:
|
||||
return calculate_card_value(a) < calculate_card_value(b)
|
||||
617
scripts/network/NetworkManager.gd
Normal file
617
scripts/network/NetworkManager.gd
Normal file
@@ -0,0 +1,617 @@
|
||||
class_name NetworkManager
|
||||
extends Node
|
||||
|
||||
## NetworkManager - Singleton for handling network communication
|
||||
## Manages authentication, WebSocket connection, and game messaging
|
||||
|
||||
# ======= SIGNALS =======
|
||||
# Connection and auth
|
||||
signal connection_state_changed(state: ConnectionState)
|
||||
signal authenticated(user_data: Dictionary)
|
||||
signal authentication_failed(error: String)
|
||||
signal logged_out
|
||||
|
||||
# Matchmaking - maps from server messages: queue_joined, queue_left, match_found, room_*
|
||||
signal matchmaking_update(data: Dictionary)
|
||||
signal queue_joined
|
||||
signal queue_left
|
||||
signal match_found(game_data: Dictionary)
|
||||
signal room_created(room_data: Dictionary)
|
||||
signal room_joined(room_data: Dictionary)
|
||||
signal room_updated(room_data: Dictionary)
|
||||
|
||||
# Game messages - maps from server 'opponent_action' message
|
||||
signal opponent_action_received(action: Dictionary)
|
||||
# Maps from server 'turn_timer' message
|
||||
signal turn_timer_update(seconds_remaining: int)
|
||||
signal game_started(game_data: Dictionary)
|
||||
signal game_ended(result: Dictionary)
|
||||
signal phase_changed(phase_data: Dictionary)
|
||||
signal action_confirmed(action_type: String)
|
||||
signal action_failed(action_type: String, error: String)
|
||||
signal opponent_disconnected(reconnect_timeout: int)
|
||||
signal opponent_reconnected
|
||||
signal game_state_sync(state: Dictionary)
|
||||
|
||||
# Error handling
|
||||
signal network_error(error: String)
|
||||
|
||||
# ======= ENUMS =======
|
||||
enum ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
AUTHENTICATING,
|
||||
AUTHENTICATED,
|
||||
IN_QUEUE,
|
||||
IN_ROOM,
|
||||
IN_GAME
|
||||
}
|
||||
|
||||
# ======= CONSTANTS =======
|
||||
const DEFAULT_HTTP_URL = "http://localhost:3000"
|
||||
const DEFAULT_WS_URL = "ws://localhost:3001"
|
||||
const HEARTBEAT_INTERVAL = 10.0 # seconds
|
||||
const RECONNECT_DELAY = 5.0
|
||||
const MAX_RECONNECT_ATTEMPTS = 3
|
||||
const TOKEN_FILE = "user://auth_token.dat"
|
||||
|
||||
# ======= STATE =======
|
||||
var connection_state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var http_base_url: String = DEFAULT_HTTP_URL
|
||||
var ws_url: String = DEFAULT_WS_URL
|
||||
|
||||
# Auth state
|
||||
var auth_token: String = ""
|
||||
var current_user: Dictionary = {}
|
||||
var is_authenticated: bool = false
|
||||
|
||||
# WebSocket
|
||||
var _websocket: WebSocketPeer = null
|
||||
var _heartbeat_timer: Timer = null
|
||||
var _reconnect_attempts: int = 0
|
||||
|
||||
# HTTP request pool
|
||||
var _http_requests: Array[HTTPRequest] = []
|
||||
|
||||
# Game session
|
||||
var current_game_id: String = ""
|
||||
var current_room_code: String = ""
|
||||
var opponent_info: Dictionary = {}
|
||||
var local_player_index: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Try to load saved token on startup
|
||||
_load_token()
|
||||
|
||||
# Setup heartbeat timer
|
||||
_heartbeat_timer = Timer.new()
|
||||
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
|
||||
_heartbeat_timer.timeout.connect(_on_heartbeat_timeout)
|
||||
add_child(_heartbeat_timer)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Poll WebSocket if connected
|
||||
if _websocket:
|
||||
_websocket.poll()
|
||||
|
||||
var state = _websocket.get_ready_state()
|
||||
match state:
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
while _websocket.get_available_packet_count() > 0:
|
||||
var packet = _websocket.get_packet()
|
||||
_on_websocket_message(packet)
|
||||
WebSocketPeer.STATE_CLOSING:
|
||||
pass # Wait for close
|
||||
WebSocketPeer.STATE_CLOSED:
|
||||
var code = _websocket.get_close_code()
|
||||
var reason = _websocket.get_close_reason()
|
||||
print("WebSocket closed: ", code, " - ", reason)
|
||||
_on_websocket_closed()
|
||||
|
||||
|
||||
# ======= CONFIGURATION =======
|
||||
|
||||
func configure(http_url: String, ws_url_param: String) -> void:
|
||||
http_base_url = http_url
|
||||
ws_url = ws_url_param
|
||||
|
||||
|
||||
# ======= HTTP AUTH API =======
|
||||
|
||||
func register(email: String, password: String, username: String) -> Dictionary:
|
||||
var result = await _http_post("/api/auth/register", {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"username": username
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func login(email: String, password: String) -> Dictionary:
|
||||
var result = await _http_post("/api/auth/login", {
|
||||
"email": email,
|
||||
"password": password
|
||||
})
|
||||
|
||||
if result.success:
|
||||
auth_token = result.token
|
||||
current_user = result.user
|
||||
is_authenticated = true
|
||||
_save_token()
|
||||
authenticated.emit(current_user)
|
||||
else:
|
||||
authentication_failed.emit(result.message)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func logout() -> void:
|
||||
auth_token = ""
|
||||
current_user = {}
|
||||
is_authenticated = false
|
||||
_clear_token()
|
||||
disconnect_websocket()
|
||||
logged_out.emit()
|
||||
|
||||
|
||||
func verify_email(token: String) -> Dictionary:
|
||||
return await _http_post("/api/auth/verify-email", { "token": token })
|
||||
|
||||
|
||||
func forgot_password(email: String) -> Dictionary:
|
||||
return await _http_post("/api/auth/forgot-password", { "email": email })
|
||||
|
||||
|
||||
func reset_password(token: String, new_password: String) -> Dictionary:
|
||||
return await _http_post("/api/auth/reset-password", {
|
||||
"token": token,
|
||||
"newPassword": new_password
|
||||
})
|
||||
|
||||
|
||||
func resend_verification(email: String) -> Dictionary:
|
||||
return await _http_post("/api/auth/resend-verification", { "email": email })
|
||||
|
||||
|
||||
func get_profile() -> Dictionary:
|
||||
return await _http_get("/api/user/profile", true)
|
||||
|
||||
|
||||
func get_match_history(limit: int = 20, offset: int = 0) -> Dictionary:
|
||||
return await _http_get("/api/user/match-history?limit=%d&offset=%d" % [limit, offset], true)
|
||||
|
||||
|
||||
func get_leaderboard(limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
return await _http_get("/api/leaderboard?limit=%d&offset=%d" % [limit, offset], false)
|
||||
|
||||
|
||||
func save_deck(name: String, card_ids: Array) -> Dictionary:
|
||||
return await _http_post("/api/user/decks", {
|
||||
"name": name,
|
||||
"cardIds": card_ids
|
||||
}, true)
|
||||
|
||||
|
||||
func delete_deck(deck_id: String) -> Dictionary:
|
||||
return await _http_delete("/api/user/decks/" + deck_id)
|
||||
|
||||
|
||||
# ======= HTTP HELPERS =======
|
||||
|
||||
func _get_http_request() -> HTTPRequest:
|
||||
# Reuse or create HTTP request node
|
||||
for req in _http_requests:
|
||||
if not req.is_inside_tree():
|
||||
add_child(req)
|
||||
# Check if request is not busy (simplified check)
|
||||
return req
|
||||
|
||||
var new_req = HTTPRequest.new()
|
||||
_http_requests.append(new_req)
|
||||
add_child(new_req)
|
||||
return new_req
|
||||
|
||||
|
||||
func _http_post(endpoint: String, body: Dictionary, auth_required: bool = false) -> Dictionary:
|
||||
var http = _get_http_request()
|
||||
var url = http_base_url + endpoint
|
||||
var headers = PackedStringArray(["Content-Type: application/json"])
|
||||
|
||||
if auth_required and auth_token != "":
|
||||
headers.append("Authorization: Bearer " + auth_token)
|
||||
|
||||
var json_body = JSON.stringify(body)
|
||||
var error = http.request(url, headers, HTTPClient.METHOD_POST, json_body)
|
||||
|
||||
if error != OK:
|
||||
return { "success": false, "message": "HTTP request failed" }
|
||||
|
||||
var result = await http.request_completed
|
||||
return _parse_http_response(result)
|
||||
|
||||
|
||||
func _http_get(endpoint: String, auth_required: bool = false) -> Dictionary:
|
||||
var http = _get_http_request()
|
||||
var url = http_base_url + endpoint
|
||||
var headers = PackedStringArray()
|
||||
|
||||
if auth_required and auth_token != "":
|
||||
headers.append("Authorization: Bearer " + auth_token)
|
||||
|
||||
var error = http.request(url, headers, HTTPClient.METHOD_GET)
|
||||
|
||||
if error != OK:
|
||||
return { "success": false, "message": "HTTP request failed" }
|
||||
|
||||
var result = await http.request_completed
|
||||
return _parse_http_response(result)
|
||||
|
||||
|
||||
func _http_delete(endpoint: String) -> Dictionary:
|
||||
var http = _get_http_request()
|
||||
var url = http_base_url + endpoint
|
||||
var headers = PackedStringArray()
|
||||
|
||||
if auth_token != "":
|
||||
headers.append("Authorization: Bearer " + auth_token)
|
||||
|
||||
var error = http.request(url, headers, HTTPClient.METHOD_DELETE)
|
||||
|
||||
if error != OK:
|
||||
return { "success": false, "message": "HTTP request failed" }
|
||||
|
||||
var result = await http.request_completed
|
||||
return _parse_http_response(result)
|
||||
|
||||
|
||||
func _parse_http_response(result: Array) -> Dictionary:
|
||||
var response_code = result[1]
|
||||
var body = result[3]
|
||||
|
||||
if response_code == 0:
|
||||
return { "success": false, "message": "Connection failed" }
|
||||
|
||||
var json = JSON.new()
|
||||
var parse_result = json.parse(body.get_string_from_utf8())
|
||||
|
||||
if parse_result != OK:
|
||||
return { "success": false, "message": "Invalid response" }
|
||||
|
||||
var data = json.data
|
||||
if data is Dictionary:
|
||||
return data
|
||||
|
||||
return { "success": false, "message": "Unexpected response format" }
|
||||
|
||||
|
||||
# ======= WEBSOCKET =======
|
||||
|
||||
func connect_websocket() -> void:
|
||||
if _websocket != null:
|
||||
disconnect_websocket()
|
||||
|
||||
_set_connection_state(ConnectionState.CONNECTING)
|
||||
|
||||
_websocket = WebSocketPeer.new()
|
||||
var error = _websocket.connect_to_url(ws_url)
|
||||
|
||||
if error != OK:
|
||||
print("WebSocket connection error: ", error)
|
||||
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||
network_error.emit("Failed to connect to server")
|
||||
return
|
||||
|
||||
# Wait for connection
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
if _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
_on_websocket_connected()
|
||||
|
||||
|
||||
func disconnect_websocket() -> void:
|
||||
if _websocket:
|
||||
_websocket.close()
|
||||
_websocket = null
|
||||
|
||||
_heartbeat_timer.stop()
|
||||
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||
current_game_id = ""
|
||||
current_room_code = ""
|
||||
|
||||
|
||||
func _on_websocket_connected() -> void:
|
||||
print("WebSocket connected")
|
||||
_set_connection_state(ConnectionState.CONNECTED)
|
||||
_reconnect_attempts = 0
|
||||
|
||||
# Authenticate with JWT
|
||||
if auth_token != "":
|
||||
_set_connection_state(ConnectionState.AUTHENTICATING)
|
||||
_send_ws_message("auth", { "token": auth_token })
|
||||
|
||||
_heartbeat_timer.start()
|
||||
|
||||
|
||||
func _on_websocket_message(data: PackedByteArray) -> void:
|
||||
var json = JSON.new()
|
||||
var parse_result = json.parse(data.get_string_from_utf8())
|
||||
|
||||
if parse_result != OK:
|
||||
push_error("Failed to parse WebSocket message: " + str(parse_result))
|
||||
network_error.emit("Invalid message from server")
|
||||
return
|
||||
|
||||
var message = json.data
|
||||
if not message is Dictionary or not message.has("type"):
|
||||
push_error("Invalid message format: missing 'type' field")
|
||||
network_error.emit("Invalid message format from server")
|
||||
return
|
||||
|
||||
_handle_ws_message(message)
|
||||
|
||||
|
||||
func _on_websocket_closed() -> void:
|
||||
_websocket = null
|
||||
_heartbeat_timer.stop()
|
||||
|
||||
var was_authenticated = connection_state == ConnectionState.AUTHENTICATED or connection_state == ConnectionState.IN_GAME
|
||||
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
# Try to reconnect if we were authenticated
|
||||
if was_authenticated and _reconnect_attempts < MAX_RECONNECT_ATTEMPTS:
|
||||
_reconnect_attempts += 1
|
||||
print("Attempting reconnect... (attempt ", _reconnect_attempts, ")")
|
||||
await get_tree().create_timer(RECONNECT_DELAY).timeout
|
||||
connect_websocket()
|
||||
|
||||
|
||||
func _handle_ws_message(message: Dictionary) -> void:
|
||||
var msg_type = message.get("type", "")
|
||||
var payload = message.get("payload", {})
|
||||
|
||||
match msg_type:
|
||||
"auth_success":
|
||||
print("WebSocket authenticated as: ", payload.get("username", ""))
|
||||
_set_connection_state(ConnectionState.AUTHENTICATED)
|
||||
|
||||
"auth_error":
|
||||
print("WebSocket auth error: ", payload.get("message", ""))
|
||||
_set_connection_state(ConnectionState.CONNECTED)
|
||||
authentication_failed.emit(payload.get("message", "Authentication failed"))
|
||||
|
||||
"pong":
|
||||
pass # Heartbeat response
|
||||
|
||||
"error":
|
||||
print("Server error: ", payload.get("message", ""))
|
||||
network_error.emit(payload.get("message", "Unknown error"))
|
||||
|
||||
"disconnected":
|
||||
print("Disconnected: ", payload.get("message", ""))
|
||||
disconnect_websocket()
|
||||
|
||||
# Matchmaking messages
|
||||
"queue_joined":
|
||||
_set_connection_state(ConnectionState.IN_QUEUE)
|
||||
queue_joined.emit()
|
||||
matchmaking_update.emit({ "type": "queue_joined", "position": payload.get("position", 0) })
|
||||
|
||||
"queue_left":
|
||||
_set_connection_state(ConnectionState.AUTHENTICATED)
|
||||
queue_left.emit()
|
||||
matchmaking_update.emit({ "type": "queue_left" })
|
||||
|
||||
"match_found":
|
||||
_set_connection_state(ConnectionState.IN_GAME)
|
||||
current_game_id = payload.get("game_id", "")
|
||||
opponent_info = payload.get("opponent", {})
|
||||
local_player_index = payload.get("your_player_index", 0)
|
||||
match_found.emit(payload)
|
||||
|
||||
"room_created":
|
||||
_set_connection_state(ConnectionState.IN_ROOM)
|
||||
current_room_code = payload.get("code", "")
|
||||
room_created.emit(payload)
|
||||
|
||||
"room_joined":
|
||||
_set_connection_state(ConnectionState.IN_ROOM)
|
||||
current_room_code = payload.get("code", "")
|
||||
room_joined.emit(payload)
|
||||
|
||||
"room_updated":
|
||||
room_updated.emit(payload)
|
||||
|
||||
"room_left":
|
||||
_set_connection_state(ConnectionState.AUTHENTICATED)
|
||||
current_room_code = ""
|
||||
matchmaking_update.emit({ "type": "room_left" })
|
||||
|
||||
# Game messages
|
||||
"game_start":
|
||||
_set_connection_state(ConnectionState.IN_GAME)
|
||||
current_game_id = payload.get("game_id", "")
|
||||
game_started.emit(payload)
|
||||
|
||||
"opponent_action":
|
||||
opponent_action_received.emit(payload)
|
||||
|
||||
"turn_timer":
|
||||
turn_timer_update.emit(payload.get("seconds_remaining", 0))
|
||||
|
||||
"phase_changed":
|
||||
phase_changed.emit(payload)
|
||||
|
||||
"action_confirmed":
|
||||
action_confirmed.emit(payload.get("action_type", ""))
|
||||
|
||||
"action_failed":
|
||||
action_failed.emit(payload.get("action_type", ""), payload.get("error", "Unknown error"))
|
||||
network_error.emit("Action failed: " + payload.get("error", "Unknown error"))
|
||||
|
||||
"opponent_disconnected":
|
||||
opponent_disconnected.emit(payload.get("reconnect_timeout_seconds", 60))
|
||||
|
||||
"opponent_reconnected":
|
||||
opponent_reconnected.emit()
|
||||
|
||||
"game_state_sync":
|
||||
game_state_sync.emit(payload)
|
||||
|
||||
"game_ended":
|
||||
game_ended.emit(payload)
|
||||
_set_connection_state(ConnectionState.AUTHENTICATED)
|
||||
current_game_id = ""
|
||||
|
||||
_:
|
||||
print("Unknown message type: ", msg_type)
|
||||
|
||||
|
||||
func _send_ws_message(type: String, payload: Dictionary) -> void:
|
||||
if _websocket == null or _websocket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||
print("Cannot send message - WebSocket not connected")
|
||||
return
|
||||
|
||||
var message = {
|
||||
"type": type,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
var json = JSON.stringify(message)
|
||||
_websocket.send_text(json)
|
||||
|
||||
|
||||
func _on_heartbeat_timeout() -> void:
|
||||
if _websocket and _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
_send_ws_message("ping", { "client_time": Time.get_unix_time_from_system() })
|
||||
|
||||
|
||||
func _set_connection_state(new_state: ConnectionState) -> void:
|
||||
if connection_state != new_state:
|
||||
connection_state = new_state
|
||||
connection_state_changed.emit(new_state)
|
||||
|
||||
|
||||
# ======= MATCHMAKING =======
|
||||
|
||||
func join_queue(deck_id: String) -> void:
|
||||
_send_ws_message("queue_join", { "deck_id": deck_id })
|
||||
|
||||
|
||||
func leave_queue() -> void:
|
||||
_send_ws_message("queue_leave", {})
|
||||
|
||||
|
||||
func create_room(deck_id: String) -> void:
|
||||
_send_ws_message("room_create", { "deck_id": deck_id })
|
||||
|
||||
|
||||
func join_room(room_code: String, deck_id: String) -> void:
|
||||
_send_ws_message("room_join", { "room_code": room_code.to_upper(), "deck_id": deck_id })
|
||||
|
||||
|
||||
func leave_room() -> void:
|
||||
_send_ws_message("room_leave", {})
|
||||
|
||||
|
||||
func set_room_ready(ready: bool) -> void:
|
||||
_send_ws_message("room_ready", { "ready": ready })
|
||||
|
||||
|
||||
# ======= GAME ACTIONS =======
|
||||
|
||||
func send_game_action(action_type: String, payload: Dictionary) -> void:
|
||||
payload["game_id"] = current_game_id
|
||||
_send_ws_message("action_" + action_type, payload)
|
||||
|
||||
|
||||
func send_play_card(card_instance_id: int) -> void:
|
||||
send_game_action("play_card", { "card_instance_id": card_instance_id })
|
||||
|
||||
|
||||
func send_attack(attacker_instance_id: int) -> void:
|
||||
send_game_action("attack", { "attacker_instance_id": attacker_instance_id })
|
||||
|
||||
|
||||
func send_block(blocker_instance_id) -> void: # Can be int or null
|
||||
send_game_action("block", { "blocker_instance_id": blocker_instance_id })
|
||||
|
||||
|
||||
func send_pass() -> void:
|
||||
send_game_action("pass", {})
|
||||
|
||||
|
||||
func send_concede() -> void:
|
||||
send_game_action("concede", {})
|
||||
|
||||
|
||||
func send_discard_for_cp(card_instance_id: int) -> void:
|
||||
send_game_action("discard_cp", { "card_instance_id": card_instance_id })
|
||||
|
||||
|
||||
func send_dull_backup_for_cp(card_instance_id: int) -> void:
|
||||
send_game_action("dull_backup_cp", { "card_instance_id": card_instance_id })
|
||||
|
||||
|
||||
func send_attack_resolved() -> void:
|
||||
send_game_action("attack_resolved", {})
|
||||
|
||||
|
||||
func send_report_game_end(winner_id: String, reason: String) -> void:
|
||||
send_game_action("report_game_end", { "winner_id": winner_id, "reason": reason })
|
||||
|
||||
|
||||
# ======= TOKEN PERSISTENCE =======
|
||||
|
||||
func _save_token() -> void:
|
||||
if auth_token == "":
|
||||
return
|
||||
|
||||
var file = FileAccess.open(TOKEN_FILE, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(auth_token)
|
||||
file.close()
|
||||
|
||||
|
||||
func _load_token() -> void:
|
||||
if not FileAccess.file_exists(TOKEN_FILE):
|
||||
return
|
||||
|
||||
var file = FileAccess.open(TOKEN_FILE, FileAccess.READ)
|
||||
if file:
|
||||
auth_token = file.get_as_text().strip_edges()
|
||||
file.close()
|
||||
|
||||
if auth_token != "":
|
||||
# Validate token by fetching profile
|
||||
var profile = await get_profile()
|
||||
if profile.success:
|
||||
current_user = profile.user
|
||||
is_authenticated = true
|
||||
authenticated.emit(current_user)
|
||||
else:
|
||||
# Token invalid, clear it
|
||||
_clear_token()
|
||||
|
||||
|
||||
func _clear_token() -> void:
|
||||
auth_token = ""
|
||||
if FileAccess.file_exists(TOKEN_FILE):
|
||||
DirAccess.remove_absolute(TOKEN_FILE)
|
||||
|
||||
|
||||
# ======= UTILITY =======
|
||||
|
||||
func get_connection_state_name() -> String:
|
||||
match connection_state:
|
||||
ConnectionState.DISCONNECTED: return "Disconnected"
|
||||
ConnectionState.CONNECTING: return "Connecting"
|
||||
ConnectionState.CONNECTED: return "Connected"
|
||||
ConnectionState.AUTHENTICATING: return "Authenticating"
|
||||
ConnectionState.AUTHENTICATED: return "Online"
|
||||
ConnectionState.IN_QUEUE: return "In Queue"
|
||||
ConnectionState.IN_ROOM: return "In Room"
|
||||
ConnectionState.IN_GAME: return "In Game"
|
||||
return "Unknown"
|
||||
347
scripts/ui/ChoiceModal.gd
Normal file
347
scripts/ui/ChoiceModal.gd
Normal file
@@ -0,0 +1,347 @@
|
||||
class_name ChoiceModal
|
||||
extends CanvasLayer
|
||||
|
||||
## ChoiceModal - UI component for multi-modal ability choices
|
||||
## Displays options like "Select 1 of 3 following actions" and returns selection
|
||||
|
||||
signal choice_made(selected_indices: Array)
|
||||
signal choice_cancelled
|
||||
|
||||
# UI Elements
|
||||
var backdrop: ColorRect
|
||||
var modal_panel: Panel
|
||||
var title_label: Label
|
||||
var options_container: VBoxContainer
|
||||
var confirm_button: Button
|
||||
var cancel_button: Button
|
||||
|
||||
# State
|
||||
var _modes: Array = []
|
||||
var _select_count: int = 1
|
||||
var _select_up_to: bool = false
|
||||
var _selected_indices: Array = []
|
||||
var _cancellable: bool = false
|
||||
var _option_buttons: Array = []
|
||||
|
||||
# Cached styles for option buttons (created once, reused)
|
||||
var _option_normal_style: StyleBoxFlat
|
||||
var _option_selected_style: StyleBoxFlat
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 200 # High z-index for modal overlay
|
||||
_create_cached_styles()
|
||||
_create_ui()
|
||||
visible = false
|
||||
|
||||
|
||||
func _create_cached_styles() -> void:
|
||||
# Normal button style
|
||||
_option_normal_style = StyleBoxFlat.new()
|
||||
_option_normal_style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
|
||||
_option_normal_style.set_border_width_all(1)
|
||||
_option_normal_style.border_color = Color(0.3, 0.3, 0.4)
|
||||
_option_normal_style.set_corner_radius_all(4)
|
||||
_option_normal_style.set_content_margin_all(10)
|
||||
|
||||
# Selected button style (gold highlight)
|
||||
_option_selected_style = StyleBoxFlat.new()
|
||||
_option_selected_style.bg_color = Color(0.25, 0.22, 0.15, 0.95)
|
||||
_option_selected_style.border_color = Color(0.7, 0.55, 0.2)
|
||||
_option_selected_style.set_border_width_all(2)
|
||||
_option_selected_style.set_corner_radius_all(4)
|
||||
_option_selected_style.set_content_margin_all(10)
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Backdrop - semi-transparent dark overlay
|
||||
backdrop = ColorRect.new()
|
||||
add_child(backdrop)
|
||||
backdrop.color = Color(0, 0, 0, 0.7)
|
||||
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
backdrop.gui_input.connect(_on_backdrop_input)
|
||||
|
||||
# Center container for modal
|
||||
var center = CenterContainer.new()
|
||||
add_child(center)
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# Modal panel
|
||||
modal_panel = Panel.new()
|
||||
center.add_child(modal_panel)
|
||||
modal_panel.custom_minimum_size = Vector2(500, 200)
|
||||
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.08, 0.08, 0.12, 0.98)
|
||||
style.border_color = Color(0.5, 0.4, 0.2) # Gold border
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(8)
|
||||
style.set_content_margin_all(20)
|
||||
modal_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
# Main vertical layout
|
||||
var vbox = VBoxContainer.new()
|
||||
modal_panel.add_child(vbox)
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.offset_left = 20
|
||||
vbox.offset_right = -20
|
||||
vbox.offset_top = 20
|
||||
vbox.offset_bottom = -20
|
||||
vbox.add_theme_constant_override("separation", 15)
|
||||
|
||||
# Title
|
||||
title_label = Label.new()
|
||||
vbox.add_child(title_label)
|
||||
title_label.text = "Select an action:"
|
||||
title_label.add_theme_font_size_override("font_size", 20)
|
||||
title_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
|
||||
# Options container
|
||||
options_container = VBoxContainer.new()
|
||||
vbox.add_child(options_container)
|
||||
options_container.add_theme_constant_override("separation", 8)
|
||||
options_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
|
||||
# Button row
|
||||
var button_row = HBoxContainer.new()
|
||||
vbox.add_child(button_row)
|
||||
button_row.add_theme_constant_override("separation", 15)
|
||||
button_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
|
||||
# Confirm button (for multi-select)
|
||||
confirm_button = _create_button("Confirm", Color(0.2, 0.5, 0.3))
|
||||
button_row.add_child(confirm_button)
|
||||
confirm_button.pressed.connect(_on_confirm_pressed)
|
||||
confirm_button.visible = false # Only shown for multi-select
|
||||
|
||||
# Cancel button
|
||||
cancel_button = _create_button("Cancel", Color(0.5, 0.3, 0.3))
|
||||
button_row.add_child(cancel_button)
|
||||
cancel_button.pressed.connect(_on_cancel_pressed)
|
||||
cancel_button.visible = false # Only shown if cancellable
|
||||
|
||||
|
||||
func _create_button(text: String, base_color: Color) -> Button:
|
||||
var button = Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(100, 40)
|
||||
|
||||
var normal_style = StyleBoxFlat.new()
|
||||
normal_style.bg_color = base_color
|
||||
normal_style.set_border_width_all(1)
|
||||
normal_style.border_color = base_color.lightened(0.3)
|
||||
normal_style.set_corner_radius_all(4)
|
||||
normal_style.set_content_margin_all(8)
|
||||
button.add_theme_stylebox_override("normal", normal_style)
|
||||
|
||||
var hover_style = normal_style.duplicate()
|
||||
hover_style.bg_color = base_color.lightened(0.15)
|
||||
button.add_theme_stylebox_override("hover", hover_style)
|
||||
|
||||
var pressed_style = normal_style.duplicate()
|
||||
pressed_style.bg_color = base_color.darkened(0.1)
|
||||
button.add_theme_stylebox_override("pressed", pressed_style)
|
||||
|
||||
return button
|
||||
|
||||
|
||||
func _create_option_button(index: int, description: String) -> Button:
|
||||
var button = Button.new()
|
||||
button.text = str(index + 1) + ". " + description
|
||||
button.custom_minimum_size = Vector2(460, 50)
|
||||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
button.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||||
|
||||
# Normal state
|
||||
var normal_style = StyleBoxFlat.new()
|
||||
normal_style.bg_color = Color(0.15, 0.15, 0.2, 0.9)
|
||||
normal_style.set_border_width_all(1)
|
||||
normal_style.border_color = Color(0.3, 0.3, 0.4)
|
||||
normal_style.set_corner_radius_all(4)
|
||||
normal_style.set_content_margin_all(10)
|
||||
button.add_theme_stylebox_override("normal", normal_style)
|
||||
|
||||
# Hover state
|
||||
var hover_style = normal_style.duplicate()
|
||||
hover_style.bg_color = Color(0.2, 0.2, 0.3, 0.95)
|
||||
hover_style.border_color = Color(0.5, 0.4, 0.2)
|
||||
button.add_theme_stylebox_override("hover", hover_style)
|
||||
|
||||
# Pressed/selected state (gold highlight)
|
||||
var pressed_style = normal_style.duplicate()
|
||||
pressed_style.bg_color = Color(0.25, 0.22, 0.15, 0.95)
|
||||
pressed_style.border_color = Color(0.7, 0.55, 0.2)
|
||||
pressed_style.set_border_width_all(2)
|
||||
button.add_theme_stylebox_override("pressed", pressed_style)
|
||||
|
||||
# Font settings
|
||||
button.add_theme_font_size_override("font_size", 14)
|
||||
button.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
|
||||
button.add_theme_color_override("font_hover_color", Color(1, 0.95, 0.8))
|
||||
|
||||
# Connect press signal
|
||||
button.pressed.connect(_on_option_pressed.bind(index))
|
||||
|
||||
return button
|
||||
|
||||
|
||||
## Show modal and await selection
|
||||
## Returns array of selected mode indices
|
||||
func show_choices(
|
||||
title: String,
|
||||
modes: Array,
|
||||
select_count: int = 1,
|
||||
select_up_to: bool = false,
|
||||
cancellable: bool = false
|
||||
) -> Array:
|
||||
_modes = modes
|
||||
_select_count = select_count
|
||||
_select_up_to = select_up_to
|
||||
_cancellable = cancellable
|
||||
_selected_indices = []
|
||||
_option_buttons = []
|
||||
|
||||
# Update title
|
||||
if select_up_to:
|
||||
title_label.text = "Select up to %d action%s:" % [select_count, "s" if select_count > 1 else ""]
|
||||
else:
|
||||
title_label.text = "Select %d action%s:" % [select_count, "s" if select_count > 1 else ""]
|
||||
|
||||
# Clear and populate options
|
||||
for child in options_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
await get_tree().process_frame # Wait for queue_free
|
||||
|
||||
for i in range(modes.size()):
|
||||
var mode = modes[i]
|
||||
var description = mode.get("description", "Option " + str(i + 1))
|
||||
var button = _create_option_button(i, description)
|
||||
options_container.add_child(button)
|
||||
_option_buttons.append(button)
|
||||
|
||||
# Show confirm button only for multi-select
|
||||
confirm_button.visible = (select_count > 1 or select_up_to)
|
||||
_update_confirm_button()
|
||||
|
||||
# Show cancel if cancellable
|
||||
cancel_button.visible = cancellable
|
||||
|
||||
# Resize panel to fit content
|
||||
await get_tree().process_frame
|
||||
var content_height = 20 + 30 + 15 + (modes.size() * 58) + 15 + 50 + 20
|
||||
modal_panel.custom_minimum_size = Vector2(500, min(content_height, 600))
|
||||
|
||||
visible = true
|
||||
|
||||
# Wait for selection
|
||||
var result = await _wait_for_selection()
|
||||
visible = false
|
||||
return result
|
||||
|
||||
|
||||
## Internal: Wait for user selection using a callback pattern
|
||||
func _wait_for_selection() -> Array:
|
||||
var result: Array = []
|
||||
|
||||
# Create a one-shot signal connection
|
||||
var completed = false
|
||||
|
||||
var on_choice = func(indices: Array):
|
||||
result = indices
|
||||
completed = true
|
||||
|
||||
var on_cancel = func():
|
||||
result = []
|
||||
completed = true
|
||||
|
||||
choice_made.connect(on_choice, CONNECT_ONE_SHOT)
|
||||
choice_cancelled.connect(on_cancel, CONNECT_ONE_SHOT)
|
||||
|
||||
# Wait until completed
|
||||
while not completed:
|
||||
await get_tree().process_frame
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _on_option_pressed(index: int) -> void:
|
||||
if _select_count == 1 and not _select_up_to:
|
||||
# Single select - immediately return
|
||||
choice_made.emit([index])
|
||||
return
|
||||
|
||||
# Multi-select - toggle selection
|
||||
if index in _selected_indices:
|
||||
_selected_indices.erase(index)
|
||||
else:
|
||||
if _selected_indices.size() < _select_count:
|
||||
_selected_indices.append(index)
|
||||
|
||||
_update_option_visuals()
|
||||
_update_confirm_button()
|
||||
|
||||
|
||||
func _update_option_visuals() -> void:
|
||||
for i in range(_option_buttons.size()):
|
||||
var button = _option_buttons[i] as Button
|
||||
var is_selected = i in _selected_indices
|
||||
|
||||
# Use cached styles instead of creating new ones each time
|
||||
if is_selected:
|
||||
button.add_theme_stylebox_override("normal", _option_selected_style)
|
||||
else:
|
||||
button.add_theme_stylebox_override("normal", _option_normal_style)
|
||||
|
||||
|
||||
func _update_confirm_button() -> void:
|
||||
if _select_up_to:
|
||||
confirm_button.disabled = false
|
||||
confirm_button.text = "Confirm (%d)" % _selected_indices.size()
|
||||
else:
|
||||
confirm_button.disabled = _selected_indices.size() != _select_count
|
||||
confirm_button.text = "Confirm (%d/%d)" % [_selected_indices.size(), _select_count]
|
||||
|
||||
|
||||
func _on_confirm_pressed() -> void:
|
||||
if _select_up_to or _selected_indices.size() == _select_count:
|
||||
choice_made.emit(_selected_indices.duplicate())
|
||||
|
||||
|
||||
func _on_cancel_pressed() -> void:
|
||||
choice_cancelled.emit()
|
||||
|
||||
|
||||
func _on_backdrop_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
if _cancellable:
|
||||
choice_cancelled.emit()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
|
||||
# Keyboard shortcuts
|
||||
if event is InputEventKey and event.pressed:
|
||||
# Number keys 1-9 for quick selection
|
||||
if event.keycode >= KEY_1 and event.keycode <= KEY_9:
|
||||
var index = event.keycode - KEY_1
|
||||
if index < _modes.size():
|
||||
_on_option_pressed(index)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
# Enter to confirm (multi-select only)
|
||||
elif event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
|
||||
if confirm_button.visible and not confirm_button.disabled:
|
||||
_on_confirm_pressed()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
# Escape to cancel
|
||||
elif event.keycode == KEY_ESCAPE:
|
||||
if _cancellable:
|
||||
choice_cancelled.emit()
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -5,7 +5,7 @@ extends CanvasLayer
|
||||
## Allows selection of game type and decks for each player
|
||||
|
||||
signal back_pressed
|
||||
signal start_game_requested(p1_deck: Array, p2_deck: Array)
|
||||
signal start_game_requested(p1_deck: Array, p2_deck: Array, is_vs_ai: bool, ai_difficulty: int)
|
||||
|
||||
const WINDOW_SIZE := Vector2(800, 600)
|
||||
|
||||
@@ -15,6 +15,8 @@ var main_vbox: VBoxContainer
|
||||
var title_label: Label
|
||||
var game_type_container: HBoxContainer
|
||||
var game_type_dropdown: OptionButton
|
||||
var ai_difficulty_container: HBoxContainer
|
||||
var ai_difficulty_dropdown: OptionButton
|
||||
var players_container: HBoxContainer
|
||||
var player1_panel: Control
|
||||
var player2_panel: Control
|
||||
@@ -25,6 +27,7 @@ var p2_preview: Control
|
||||
var buttons_container: HBoxContainer
|
||||
var start_button: Button
|
||||
var back_button: Button
|
||||
var p2_title_label: Label # Reference to update "PLAYER 2" / "AI OPPONENT"
|
||||
|
||||
# Deck data
|
||||
var saved_decks: Array[String] = []
|
||||
@@ -32,6 +35,10 @@ var starter_decks: Array = [] # Array of StarterDeckData
|
||||
var p1_selected_deck: Array = [] # Card IDs
|
||||
var p2_selected_deck: Array = [] # Card IDs
|
||||
|
||||
# AI settings
|
||||
var is_vs_ai: bool = false
|
||||
var ai_difficulty: int = AIStrategy.Difficulty.NORMAL # Default to Normal
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Set high layer to be on top of everything
|
||||
@@ -114,14 +121,37 @@ func _create_game_type_selector() -> void:
|
||||
game_type_container.add_child(label)
|
||||
|
||||
game_type_dropdown = OptionButton.new()
|
||||
game_type_dropdown.custom_minimum_size = Vector2(250, 36)
|
||||
game_type_dropdown.add_item("2-Player Local (Share Screen)")
|
||||
game_type_dropdown.add_item("vs AI (Coming Soon)")
|
||||
game_type_dropdown.set_item_disabled(1, true)
|
||||
game_type_dropdown.custom_minimum_size = Vector2(200, 36)
|
||||
game_type_dropdown.add_item("2-Player Local")
|
||||
game_type_dropdown.add_item("vs AI")
|
||||
game_type_dropdown.add_theme_font_size_override("font_size", 14)
|
||||
game_type_dropdown.item_selected.connect(_on_game_type_changed)
|
||||
_style_dropdown(game_type_dropdown)
|
||||
game_type_container.add_child(game_type_dropdown)
|
||||
|
||||
# AI Difficulty dropdown (initially hidden)
|
||||
ai_difficulty_container = HBoxContainer.new()
|
||||
ai_difficulty_container.add_theme_constant_override("separation", 10)
|
||||
ai_difficulty_container.visible = false
|
||||
game_type_container.add_child(ai_difficulty_container)
|
||||
|
||||
var diff_label = Label.new()
|
||||
diff_label.text = "Difficulty:"
|
||||
diff_label.add_theme_font_size_override("font_size", 18)
|
||||
diff_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
ai_difficulty_container.add_child(diff_label)
|
||||
|
||||
ai_difficulty_dropdown = OptionButton.new()
|
||||
ai_difficulty_dropdown.custom_minimum_size = Vector2(120, 36)
|
||||
ai_difficulty_dropdown.add_item("Easy")
|
||||
ai_difficulty_dropdown.add_item("Normal")
|
||||
ai_difficulty_dropdown.add_item("Hard")
|
||||
ai_difficulty_dropdown.select(1) # Default to Normal
|
||||
ai_difficulty_dropdown.add_theme_font_size_override("font_size", 14)
|
||||
ai_difficulty_dropdown.item_selected.connect(_on_ai_difficulty_changed)
|
||||
_style_dropdown(ai_difficulty_dropdown)
|
||||
ai_difficulty_container.add_child(ai_difficulty_dropdown)
|
||||
|
||||
|
||||
func _create_player_panels() -> void:
|
||||
players_container = HBoxContainer.new()
|
||||
@@ -157,12 +187,17 @@ func _create_player_panel(title: String, player_num: int) -> Control:
|
||||
margin.add_child(inner_vbox)
|
||||
|
||||
# Player title
|
||||
var title_label = Label.new()
|
||||
title_label.text = title
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title_label.add_theme_font_size_override("font_size", 18)
|
||||
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
|
||||
inner_vbox.add_child(title_label)
|
||||
var player_title = Label.new()
|
||||
player_title.name = "TitleLabel"
|
||||
player_title.text = title
|
||||
player_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
player_title.add_theme_font_size_override("font_size", 18)
|
||||
player_title.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
|
||||
inner_vbox.add_child(player_title)
|
||||
|
||||
# Store reference for Player 2 to update when AI mode changes
|
||||
if player_num == 2:
|
||||
p2_title_label = player_title
|
||||
|
||||
# Deck dropdown
|
||||
var dropdown = OptionButton.new()
|
||||
@@ -579,10 +614,26 @@ func _create_separator_style() -> StyleBoxFlat:
|
||||
return style
|
||||
|
||||
|
||||
func _on_game_type_changed(index: int) -> void:
|
||||
is_vs_ai = (index == 1)
|
||||
ai_difficulty_container.visible = is_vs_ai
|
||||
|
||||
# Update Player 2 panel title
|
||||
if p2_title_label:
|
||||
if is_vs_ai:
|
||||
p2_title_label.text = "AI OPPONENT"
|
||||
else:
|
||||
p2_title_label.text = "PLAYER 2"
|
||||
|
||||
|
||||
func _on_ai_difficulty_changed(index: int) -> void:
|
||||
ai_difficulty = index # 0=Easy, 1=Normal, 2=Hard
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
func _on_start_pressed() -> void:
|
||||
if p1_selected_deck.size() >= 1 and p2_selected_deck.size() >= 1:
|
||||
start_game_requested.emit(p1_selected_deck, p2_selected_deck)
|
||||
start_game_requested.emit(p1_selected_deck, p2_selected_deck, is_vs_ai, ai_difficulty)
|
||||
|
||||
@@ -309,6 +309,14 @@ func _show_next_message() -> void:
|
||||
func _on_message_timer_timeout() -> void:
|
||||
_show_next_message()
|
||||
|
||||
|
||||
## Hide message immediately (e.g., when AI finishes thinking)
|
||||
func hide_message() -> void:
|
||||
message_queue.clear()
|
||||
message_panel.visible = false
|
||||
message_timer.stop()
|
||||
|
||||
|
||||
## Show card detail panel
|
||||
func show_card_detail(card: CardInstance) -> void:
|
||||
if not card or not card.card_data:
|
||||
|
||||
442
scripts/ui/LeaderboardScreen.gd
Normal file
442
scripts/ui/LeaderboardScreen.gd
Normal file
@@ -0,0 +1,442 @@
|
||||
class_name LeaderboardScreen
|
||||
extends CanvasLayer
|
||||
|
||||
## LeaderboardScreen - Displays top players ranked by ELO
|
||||
|
||||
signal back_pressed
|
||||
|
||||
# Window dimensions
|
||||
const WINDOW_SIZE := Vector2i(600, 700)
|
||||
|
||||
# Pagination
|
||||
const PLAYERS_PER_PAGE = 20
|
||||
|
||||
# UI Components
|
||||
var main_container: VBoxContainer
|
||||
var back_button: Button
|
||||
var title_label: Label
|
||||
var info_label: Label
|
||||
var leaderboard_section: PanelContainer
|
||||
var leaderboard_list: VBoxContainer
|
||||
var header_row: HBoxContainer
|
||||
var pagination_container: HBoxContainer
|
||||
var prev_button: Button
|
||||
var page_label: Label
|
||||
var next_button: Button
|
||||
var loading_label: Label
|
||||
var error_label: Label
|
||||
|
||||
# State
|
||||
var current_page: int = 0
|
||||
var is_loading: bool = false
|
||||
var players_cache: Array = []
|
||||
|
||||
# Styling
|
||||
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
|
||||
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
|
||||
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
|
||||
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
|
||||
const GOLD_COLOR := Color(1.0, 0.84, 0.0, 1.0)
|
||||
const SILVER_COLOR := Color(0.75, 0.75, 0.75, 1.0)
|
||||
const BRONZE_COLOR := Color(0.8, 0.5, 0.2, 1.0)
|
||||
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
|
||||
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
|
||||
const HIGHLIGHT_COLOR := Color(0.3, 0.35, 0.5, 1.0)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_create_ui()
|
||||
_load_leaderboard()
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Background
|
||||
var bg = ColorRect.new()
|
||||
bg.color = BG_COLOR
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(bg)
|
||||
|
||||
# Main container
|
||||
main_container = VBoxContainer.new()
|
||||
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_theme_constant_override("separation", 16)
|
||||
add_child(main_container)
|
||||
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_right", 24)
|
||||
margin.add_theme_constant_override("margin_top", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 16)
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_child(margin)
|
||||
|
||||
var content = VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 16)
|
||||
margin.add_child(content)
|
||||
|
||||
# Back button
|
||||
back_button = _create_button("< Back", false)
|
||||
back_button.custom_minimum_size = Vector2(80, 32)
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
content.add_child(back_button)
|
||||
|
||||
# Title
|
||||
title_label = Label.new()
|
||||
title_label.add_theme_font_override("font", custom_font)
|
||||
title_label.add_theme_font_size_override("font_size", 28)
|
||||
title_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title_label.text = "LEADERBOARD"
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(title_label)
|
||||
|
||||
# Info label
|
||||
info_label = Label.new()
|
||||
info_label.add_theme_font_override("font", custom_font)
|
||||
info_label.add_theme_font_size_override("font_size", 12)
|
||||
info_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
info_label.text = "Minimum 10 games required to appear on leaderboard"
|
||||
info_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(info_label)
|
||||
|
||||
# Leaderboard section
|
||||
_create_leaderboard_section(content)
|
||||
|
||||
# Error label
|
||||
error_label = Label.new()
|
||||
error_label.add_theme_font_override("font", custom_font)
|
||||
error_label.add_theme_font_size_override("font_size", 14)
|
||||
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
error_label.visible = false
|
||||
content.add_child(error_label)
|
||||
|
||||
|
||||
func _create_leaderboard_section(parent: VBoxContainer) -> void:
|
||||
leaderboard_section = _create_panel_section("TOP PLAYERS")
|
||||
leaderboard_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(leaderboard_section)
|
||||
|
||||
var content = leaderboard_section.get_child(0) as VBoxContainer
|
||||
|
||||
# Header row
|
||||
header_row = _create_header_row()
|
||||
content.add_child(header_row)
|
||||
|
||||
# Separator
|
||||
var separator = HSeparator.new()
|
||||
var sep_style = StyleBoxFlat.new()
|
||||
sep_style.bg_color = MUTED_COLOR
|
||||
sep_style.content_margin_top = 1
|
||||
separator.add_theme_stylebox_override("separator", sep_style)
|
||||
content.add_child(separator)
|
||||
|
||||
# Loading indicator
|
||||
loading_label = Label.new()
|
||||
loading_label.add_theme_font_override("font", custom_font)
|
||||
loading_label.add_theme_font_size_override("font_size", 14)
|
||||
loading_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
loading_label.text = "Loading..."
|
||||
loading_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(loading_label)
|
||||
|
||||
# Player list
|
||||
leaderboard_list = VBoxContainer.new()
|
||||
leaderboard_list.add_theme_constant_override("separation", 4)
|
||||
leaderboard_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content.add_child(leaderboard_list)
|
||||
|
||||
# Pagination
|
||||
pagination_container = HBoxContainer.new()
|
||||
pagination_container.add_theme_constant_override("separation", 16)
|
||||
pagination_container.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_child(pagination_container)
|
||||
|
||||
prev_button = _create_button("< Prev", false)
|
||||
prev_button.custom_minimum_size = Vector2(80, 32)
|
||||
prev_button.pressed.connect(_on_prev_page)
|
||||
pagination_container.add_child(prev_button)
|
||||
|
||||
page_label = Label.new()
|
||||
page_label.add_theme_font_override("font", custom_font)
|
||||
page_label.add_theme_font_size_override("font_size", 14)
|
||||
page_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
page_label.text = "Page 1"
|
||||
pagination_container.add_child(page_label)
|
||||
|
||||
next_button = _create_button("Next >", false)
|
||||
next_button.custom_minimum_size = Vector2(80, 32)
|
||||
next_button.pressed.connect(_on_next_page)
|
||||
pagination_container.add_child(next_button)
|
||||
|
||||
|
||||
func _create_header_row() -> HBoxContainer:
|
||||
var row = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
|
||||
# Rank
|
||||
var rank_header = _create_header_label("RANK", 50)
|
||||
row.add_child(rank_header)
|
||||
|
||||
# Player
|
||||
var player_header = _create_header_label("PLAYER", 0)
|
||||
player_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(player_header)
|
||||
|
||||
# ELO
|
||||
var elo_header = _create_header_label("ELO", 60)
|
||||
elo_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(elo_header)
|
||||
|
||||
# Win Rate
|
||||
var wr_header = _create_header_label("WIN%", 60)
|
||||
wr_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(wr_header)
|
||||
|
||||
# Games
|
||||
var games_header = _create_header_label("GAMES", 60)
|
||||
games_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(games_header)
|
||||
|
||||
return row
|
||||
|
||||
|
||||
func _create_header_label(text: String, min_width: int) -> Label:
|
||||
var label = Label.new()
|
||||
label.add_theme_font_override("font", custom_font)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
label.text = text
|
||||
if min_width > 0:
|
||||
label.custom_minimum_size.x = min_width
|
||||
return label
|
||||
|
||||
|
||||
func _create_panel_section(title: String) -> PanelContainer:
|
||||
var panel = PanelContainer.new()
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = PANEL_COLOR
|
||||
style.set_corner_radius_all(8)
|
||||
style.set_content_margin_all(16)
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(vbox)
|
||||
|
||||
var title_label = Label.new()
|
||||
title_label.add_theme_font_override("font", custom_font)
|
||||
title_label.add_theme_font_size_override("font_size", 18)
|
||||
title_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title_label.text = title
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title_label)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _create_button(text: String, primary: bool) -> Button:
|
||||
var button = Button.new()
|
||||
button.text = text
|
||||
button.add_theme_font_override("font", custom_font)
|
||||
button.add_theme_font_size_override("font_size", 16)
|
||||
button.custom_minimum_size = Vector2(0, 40)
|
||||
|
||||
var normal = StyleBoxFlat.new()
|
||||
var hover = StyleBoxFlat.new()
|
||||
var pressed = StyleBoxFlat.new()
|
||||
var disabled = StyleBoxFlat.new()
|
||||
|
||||
if primary:
|
||||
normal.bg_color = ACCENT_COLOR
|
||||
hover.bg_color = ACCENT_COLOR.lightened(0.15)
|
||||
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
else:
|
||||
normal.bg_color = Color(0.25, 0.23, 0.3)
|
||||
hover.bg_color = Color(0.3, 0.28, 0.35)
|
||||
pressed.bg_color = Color(0.2, 0.18, 0.25)
|
||||
button.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
|
||||
disabled.bg_color = Color(0.2, 0.18, 0.22)
|
||||
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
|
||||
|
||||
for style in [normal, hover, pressed, disabled]:
|
||||
style.set_corner_radius_all(6)
|
||||
style.set_content_margin_all(8)
|
||||
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", pressed)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
|
||||
return button
|
||||
|
||||
|
||||
func _load_leaderboard() -> void:
|
||||
is_loading = true
|
||||
loading_label.visible = true
|
||||
_clear_leaderboard_list()
|
||||
|
||||
var offset = current_page * PLAYERS_PER_PAGE
|
||||
var result = await NetworkManager.get_leaderboard(PLAYERS_PER_PAGE, offset)
|
||||
|
||||
loading_label.visible = false
|
||||
is_loading = false
|
||||
|
||||
if result.success:
|
||||
var players = result.get("players", [])
|
||||
players_cache = players
|
||||
_display_players(players)
|
||||
_update_pagination()
|
||||
else:
|
||||
_show_error(result.get("message", "Failed to load leaderboard"))
|
||||
|
||||
|
||||
func _display_players(players: Array) -> void:
|
||||
_clear_leaderboard_list()
|
||||
|
||||
if players.is_empty():
|
||||
var empty_label = Label.new()
|
||||
empty_label.add_theme_font_override("font", custom_font)
|
||||
empty_label.add_theme_font_size_override("font_size", 14)
|
||||
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
empty_label.text = "No players found"
|
||||
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
leaderboard_list.add_child(empty_label)
|
||||
return
|
||||
|
||||
for player_data in players:
|
||||
var player_row = _create_player_row(player_data)
|
||||
leaderboard_list.add_child(player_row)
|
||||
|
||||
|
||||
func _create_player_row(player_data: Dictionary) -> PanelContainer:
|
||||
var panel = PanelContainer.new()
|
||||
var style = StyleBoxFlat.new()
|
||||
style.set_corner_radius_all(4)
|
||||
style.set_content_margin_all(8)
|
||||
|
||||
# Check if this is the current user
|
||||
var current_username = ""
|
||||
if NetworkManager and NetworkManager.is_authenticated:
|
||||
current_username = NetworkManager.current_user.get("username", "")
|
||||
|
||||
var is_current_user = player_data.get("username", "") == current_username
|
||||
if is_current_user:
|
||||
style.bg_color = HIGHLIGHT_COLOR
|
||||
else:
|
||||
style.bg_color = Color(0.15, 0.14, 0.18, 0.5)
|
||||
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var row = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(row)
|
||||
|
||||
var rank = player_data.get("rank", 0)
|
||||
|
||||
# Rank with medal colors for top 3
|
||||
var rank_label = Label.new()
|
||||
rank_label.add_theme_font_override("font", custom_font)
|
||||
rank_label.add_theme_font_size_override("font_size", 16)
|
||||
rank_label.custom_minimum_size.x = 50
|
||||
rank_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
|
||||
match rank:
|
||||
1:
|
||||
rank_label.text = "#1"
|
||||
rank_label.add_theme_color_override("font_color", GOLD_COLOR)
|
||||
2:
|
||||
rank_label.text = "#2"
|
||||
rank_label.add_theme_color_override("font_color", SILVER_COLOR)
|
||||
3:
|
||||
rank_label.text = "#3"
|
||||
rank_label.add_theme_color_override("font_color", BRONZE_COLOR)
|
||||
_:
|
||||
rank_label.text = "#%d" % rank
|
||||
rank_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
row.add_child(rank_label)
|
||||
|
||||
# Player name
|
||||
var name_label = Label.new()
|
||||
name_label.add_theme_font_override("font", custom_font)
|
||||
name_label.add_theme_font_size_override("font_size", 16)
|
||||
name_label.add_theme_color_override("font_color", ACCENT_COLOR if is_current_user else TEXT_COLOR)
|
||||
name_label.text = player_data.get("username", "Unknown")
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(name_label)
|
||||
|
||||
# ELO
|
||||
var elo_label = Label.new()
|
||||
elo_label.add_theme_font_override("font", custom_font)
|
||||
elo_label.add_theme_font_size_override("font_size", 16)
|
||||
elo_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
elo_label.text = str(player_data.get("eloRating", 1000))
|
||||
elo_label.custom_minimum_size.x = 60
|
||||
elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(elo_label)
|
||||
|
||||
# Win rate
|
||||
var wr_label = Label.new()
|
||||
wr_label.add_theme_font_override("font", custom_font)
|
||||
wr_label.add_theme_font_size_override("font_size", 14)
|
||||
wr_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
wr_label.text = "%d%%" % player_data.get("winRate", 0)
|
||||
wr_label.custom_minimum_size.x = 60
|
||||
wr_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(wr_label)
|
||||
|
||||
# Games played
|
||||
var games_label = Label.new()
|
||||
games_label.add_theme_font_override("font", custom_font)
|
||||
games_label.add_theme_font_size_override("font_size", 14)
|
||||
games_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
games_label.text = str(player_data.get("gamesPlayed", 0))
|
||||
games_label.custom_minimum_size.x = 60
|
||||
games_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(games_label)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _clear_leaderboard_list() -> void:
|
||||
for child in leaderboard_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
func _update_pagination() -> void:
|
||||
page_label.text = "Page %d" % (current_page + 1)
|
||||
prev_button.disabled = current_page == 0
|
||||
# Disable next if we got fewer results than requested
|
||||
next_button.disabled = players_cache.size() < PLAYERS_PER_PAGE
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
await get_tree().create_timer(5.0).timeout
|
||||
if is_instance_valid(error_label):
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
func _on_prev_page() -> void:
|
||||
if current_page > 0:
|
||||
current_page -= 1
|
||||
_load_leaderboard()
|
||||
|
||||
|
||||
func _on_next_page() -> void:
|
||||
current_page += 1
|
||||
_load_leaderboard()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
current_page = 0
|
||||
_load_leaderboard()
|
||||
371
scripts/ui/LoginScreen.gd
Normal file
371
scripts/ui/LoginScreen.gd
Normal file
@@ -0,0 +1,371 @@
|
||||
class_name LoginScreen
|
||||
extends CanvasLayer
|
||||
|
||||
## LoginScreen - Email/password login form for online play
|
||||
|
||||
signal login_successful(user_data: Dictionary)
|
||||
signal register_requested
|
||||
signal forgot_password_requested
|
||||
signal back_pressed
|
||||
|
||||
const WINDOW_SIZE := Vector2(400, 500)
|
||||
|
||||
# UI Components
|
||||
var background: PanelContainer
|
||||
var main_vbox: VBoxContainer
|
||||
var title_label: Label
|
||||
var email_input: LineEdit
|
||||
var password_input: LineEdit
|
||||
var login_button: Button
|
||||
var register_link: Button
|
||||
var forgot_password_link: Button
|
||||
var back_button: Button
|
||||
var error_label: Label
|
||||
var loading_spinner: Control
|
||||
var status_label: Label
|
||||
|
||||
# State
|
||||
var _is_loading: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 100
|
||||
_create_ui()
|
||||
|
||||
# Connect to NetworkManager signals
|
||||
if NetworkManager:
|
||||
NetworkManager.authenticated.connect(_on_authenticated)
|
||||
NetworkManager.authentication_failed.connect(_on_auth_failed)
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Background panel
|
||||
background = PanelContainer.new()
|
||||
add_child(background)
|
||||
background.position = Vector2.ZERO
|
||||
background.size = WINDOW_SIZE
|
||||
background.add_theme_stylebox_override("panel", _create_panel_style())
|
||||
|
||||
# Main layout with margin
|
||||
var margin = MarginContainer.new()
|
||||
background.add_child(margin)
|
||||
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 40)
|
||||
margin.add_theme_constant_override("margin_right", 40)
|
||||
margin.add_theme_constant_override("margin_top", 30)
|
||||
margin.add_theme_constant_override("margin_bottom", 30)
|
||||
|
||||
main_vbox = VBoxContainer.new()
|
||||
margin.add_child(main_vbox)
|
||||
main_vbox.add_theme_constant_override("separation", 15)
|
||||
|
||||
# Title
|
||||
_create_title()
|
||||
|
||||
# Login form
|
||||
_create_form()
|
||||
|
||||
# Error label
|
||||
_create_error_label()
|
||||
|
||||
# Spacer
|
||||
var spacer = Control.new()
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
main_vbox.add_child(spacer)
|
||||
|
||||
# Links
|
||||
_create_links()
|
||||
|
||||
# Back button
|
||||
_create_back_button()
|
||||
|
||||
|
||||
func _create_title() -> void:
|
||||
title_label = Label.new()
|
||||
title_label.text = "LOGIN"
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title_label.add_theme_font_size_override("font_size", 32)
|
||||
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
|
||||
main_vbox.add_child(title_label)
|
||||
|
||||
# Separator
|
||||
var separator = HSeparator.new()
|
||||
separator.add_theme_stylebox_override("separator", _create_separator_style())
|
||||
main_vbox.add_child(separator)
|
||||
|
||||
# Spacer
|
||||
var spacer = Control.new()
|
||||
spacer.custom_minimum_size.y = 20
|
||||
main_vbox.add_child(spacer)
|
||||
|
||||
|
||||
func _create_form() -> void:
|
||||
# Email field
|
||||
var email_label = Label.new()
|
||||
email_label.text = "Email"
|
||||
email_label.add_theme_font_size_override("font_size", 16)
|
||||
email_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(email_label)
|
||||
|
||||
email_input = LineEdit.new()
|
||||
email_input.placeholder_text = "Enter your email"
|
||||
email_input.custom_minimum_size = Vector2(0, 40)
|
||||
_style_input(email_input)
|
||||
email_input.text_submitted.connect(_on_input_submitted)
|
||||
main_vbox.add_child(email_input)
|
||||
|
||||
# Password field
|
||||
var password_label = Label.new()
|
||||
password_label.text = "Password"
|
||||
password_label.add_theme_font_size_override("font_size", 16)
|
||||
password_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(password_label)
|
||||
|
||||
password_input = LineEdit.new()
|
||||
password_input.placeholder_text = "Enter your password"
|
||||
password_input.secret = true
|
||||
password_input.custom_minimum_size = Vector2(0, 40)
|
||||
_style_input(password_input)
|
||||
password_input.text_submitted.connect(_on_input_submitted)
|
||||
main_vbox.add_child(password_input)
|
||||
|
||||
# Login button
|
||||
var button_spacer = Control.new()
|
||||
button_spacer.custom_minimum_size.y = 10
|
||||
main_vbox.add_child(button_spacer)
|
||||
|
||||
login_button = Button.new()
|
||||
login_button.text = "Login"
|
||||
login_button.custom_minimum_size = Vector2(0, 45)
|
||||
_style_button(login_button, true)
|
||||
login_button.pressed.connect(_on_login_pressed)
|
||||
main_vbox.add_child(login_button)
|
||||
|
||||
|
||||
func _create_error_label() -> void:
|
||||
error_label = Label.new()
|
||||
error_label.text = ""
|
||||
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
error_label.add_theme_font_size_override("font_size", 14)
|
||||
error_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
error_label.visible = false
|
||||
main_vbox.add_child(error_label)
|
||||
|
||||
|
||||
func _create_links() -> void:
|
||||
var links_container = VBoxContainer.new()
|
||||
links_container.add_theme_constant_override("separation", 8)
|
||||
main_vbox.add_child(links_container)
|
||||
|
||||
# Register link
|
||||
register_link = Button.new()
|
||||
register_link.text = "Don't have an account? Register"
|
||||
register_link.flat = true
|
||||
register_link.add_theme_font_size_override("font_size", 14)
|
||||
register_link.add_theme_color_override("font_color", Color(0.6, 0.7, 1.0))
|
||||
register_link.add_theme_color_override("font_hover_color", Color(0.8, 0.85, 1.0))
|
||||
register_link.pressed.connect(_on_register_pressed)
|
||||
links_container.add_child(register_link)
|
||||
|
||||
# Forgot password link
|
||||
forgot_password_link = Button.new()
|
||||
forgot_password_link.text = "Forgot Password?"
|
||||
forgot_password_link.flat = true
|
||||
forgot_password_link.add_theme_font_size_override("font_size", 14)
|
||||
forgot_password_link.add_theme_color_override("font_color", Color(0.7, 0.7, 0.8))
|
||||
forgot_password_link.add_theme_color_override("font_hover_color", Color(0.9, 0.9, 1.0))
|
||||
forgot_password_link.pressed.connect(_on_forgot_password_pressed)
|
||||
links_container.add_child(forgot_password_link)
|
||||
|
||||
|
||||
func _create_back_button() -> void:
|
||||
var button_container = HBoxContainer.new()
|
||||
button_container.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
main_vbox.add_child(button_container)
|
||||
|
||||
back_button = Button.new()
|
||||
back_button.text = "Back"
|
||||
back_button.custom_minimum_size = Vector2(100, 40)
|
||||
_style_button(back_button, false)
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
button_container.add_child(back_button)
|
||||
|
||||
|
||||
# ======= STYLING =======
|
||||
|
||||
func _create_panel_style() -> StyleBoxFlat:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.08, 0.08, 0.12, 1.0)
|
||||
style.set_border_width_all(0)
|
||||
style.set_corner_radius_all(0)
|
||||
return style
|
||||
|
||||
|
||||
func _create_separator_style() -> StyleBoxFlat:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.5, 0.4, 0.2, 0.5)
|
||||
style.content_margin_top = 1
|
||||
return style
|
||||
|
||||
|
||||
func _style_input(input: LineEdit) -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.12, 0.12, 0.16)
|
||||
style.border_color = Color(0.4, 0.35, 0.25)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(4)
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_bottom = 8
|
||||
|
||||
input.add_theme_stylebox_override("normal", style)
|
||||
|
||||
var focus_style = style.duplicate()
|
||||
focus_style.border_color = Color(0.7, 0.6, 0.3)
|
||||
input.add_theme_stylebox_override("focus", focus_style)
|
||||
|
||||
input.add_theme_color_override("font_color", Color(0.95, 0.9, 0.8))
|
||||
input.add_theme_color_override("font_placeholder_color", Color(0.5, 0.5, 0.55))
|
||||
input.add_theme_font_size_override("font_size", 16)
|
||||
|
||||
|
||||
func _style_button(button: Button, is_primary: bool) -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
if is_primary:
|
||||
style.bg_color = Color(0.3, 0.25, 0.15)
|
||||
style.border_color = Color(0.6, 0.5, 0.3)
|
||||
else:
|
||||
style.bg_color = Color(0.15, 0.15, 0.2)
|
||||
style.border_color = Color(0.4, 0.35, 0.25)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(6)
|
||||
style.content_margin_left = 20
|
||||
style.content_margin_right = 20
|
||||
style.content_margin_top = 10
|
||||
style.content_margin_bottom = 10
|
||||
|
||||
button.add_theme_stylebox_override("normal", style)
|
||||
|
||||
var hover_style = style.duplicate()
|
||||
if is_primary:
|
||||
hover_style.bg_color = Color(0.4, 0.35, 0.2)
|
||||
hover_style.border_color = Color(0.8, 0.7, 0.4)
|
||||
else:
|
||||
hover_style.bg_color = Color(0.2, 0.2, 0.25)
|
||||
hover_style.border_color = Color(0.5, 0.45, 0.35)
|
||||
button.add_theme_stylebox_override("hover", hover_style)
|
||||
|
||||
var pressed_style = style.duplicate()
|
||||
pressed_style.bg_color = Color(0.1, 0.1, 0.12)
|
||||
button.add_theme_stylebox_override("pressed", pressed_style)
|
||||
|
||||
var disabled_style = style.duplicate()
|
||||
disabled_style.bg_color = Color(0.1, 0.1, 0.12)
|
||||
disabled_style.border_color = Color(0.25, 0.25, 0.3)
|
||||
button.add_theme_stylebox_override("disabled", disabled_style)
|
||||
|
||||
button.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
button.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.8))
|
||||
button.add_theme_color_override("font_pressed_color", Color(0.7, 0.65, 0.55))
|
||||
button.add_theme_color_override("font_disabled_color", Color(0.45, 0.42, 0.38))
|
||||
button.add_theme_font_size_override("font_size", 18)
|
||||
|
||||
|
||||
# ======= EVENT HANDLERS =======
|
||||
|
||||
func _on_input_submitted(_text: String) -> void:
|
||||
_on_login_pressed()
|
||||
|
||||
|
||||
func _on_login_pressed() -> void:
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
var email = email_input.text.strip_edges()
|
||||
var password = password_input.text
|
||||
|
||||
# Validate inputs
|
||||
if email.is_empty():
|
||||
_show_error("Please enter your email")
|
||||
return
|
||||
|
||||
if password.is_empty():
|
||||
_show_error("Please enter your password")
|
||||
return
|
||||
|
||||
if not _is_valid_email(email):
|
||||
_show_error("Please enter a valid email address")
|
||||
return
|
||||
|
||||
# Start login
|
||||
_set_loading(true)
|
||||
_hide_error()
|
||||
|
||||
var result = await NetworkManager.login(email, password)
|
||||
|
||||
_set_loading(false)
|
||||
|
||||
if result.success:
|
||||
login_successful.emit(result.user)
|
||||
else:
|
||||
_show_error(result.message)
|
||||
|
||||
|
||||
func _on_register_pressed() -> void:
|
||||
register_requested.emit()
|
||||
|
||||
|
||||
func _on_forgot_password_pressed() -> void:
|
||||
forgot_password_requested.emit()
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
func _on_authenticated(user_data: Dictionary) -> void:
|
||||
login_successful.emit(user_data)
|
||||
|
||||
|
||||
func _on_auth_failed(error: String) -> void:
|
||||
_set_loading(false)
|
||||
_show_error(error)
|
||||
|
||||
|
||||
# ======= HELPERS =======
|
||||
|
||||
func _is_valid_email(email: String) -> bool:
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
|
||||
return regex.search(email) != null
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
|
||||
|
||||
func _hide_error() -> void:
|
||||
error_label.text = ""
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
func _set_loading(loading: bool) -> void:
|
||||
_is_loading = loading
|
||||
login_button.disabled = loading
|
||||
login_button.text = "Logging in..." if loading else "Login"
|
||||
email_input.editable = not loading
|
||||
password_input.editable = not loading
|
||||
|
||||
|
||||
func clear_form() -> void:
|
||||
email_input.text = ""
|
||||
password_input.text = ""
|
||||
_hide_error()
|
||||
_set_loading(false)
|
||||
|
||||
|
||||
func focus_email() -> void:
|
||||
email_input.grab_focus()
|
||||
@@ -76,7 +76,7 @@ func _create_menu() -> void:
|
||||
deck_builder_button.pressed.connect(_on_deck_builder_pressed)
|
||||
|
||||
online_button = _create_overlay_button("Online", 2)
|
||||
online_button.disabled = true
|
||||
online_button.pressed.connect(_on_online_pressed)
|
||||
|
||||
settings_button = _create_overlay_button("Settings", 3)
|
||||
settings_button.disabled = true
|
||||
@@ -168,6 +168,10 @@ func _on_deck_builder_pressed() -> void:
|
||||
deck_builder.emit()
|
||||
|
||||
|
||||
func _on_online_pressed() -> void:
|
||||
online_game.emit()
|
||||
|
||||
|
||||
func _on_quit_pressed() -> void:
|
||||
quit_game.emit()
|
||||
get_tree().quit()
|
||||
|
||||
661
scripts/ui/OnlineLobby.gd
Normal file
661
scripts/ui/OnlineLobby.gd
Normal file
@@ -0,0 +1,661 @@
|
||||
class_name OnlineLobby
|
||||
extends CanvasLayer
|
||||
|
||||
## OnlineLobby - Matchmaking UI for ranked queue and private rooms
|
||||
|
||||
signal game_starting(game_data: Dictionary)
|
||||
signal back_pressed
|
||||
signal profile_requested
|
||||
signal leaderboard_requested
|
||||
|
||||
# Window dimensions
|
||||
const WINDOW_SIZE := Vector2i(600, 700)
|
||||
|
||||
# UI Components
|
||||
var main_container: VBoxContainer
|
||||
var back_button: Button
|
||||
var header_container: HBoxContainer
|
||||
var username_label: Label
|
||||
var elo_label: Label
|
||||
var deck_section: VBoxContainer
|
||||
var deck_dropdown: OptionButton
|
||||
var ranked_section: PanelContainer
|
||||
var ranked_content: VBoxContainer
|
||||
var find_match_button: Button
|
||||
var cancel_search_button: Button
|
||||
var queue_status_label: Label
|
||||
var private_section: PanelContainer
|
||||
var private_content: VBoxContainer
|
||||
var create_room_button: Button
|
||||
var join_container: HBoxContainer
|
||||
var room_code_input: LineEdit
|
||||
var join_room_button: Button
|
||||
var room_section: PanelContainer
|
||||
var room_content: VBoxContainer
|
||||
var room_code_label: Label
|
||||
var copy_code_button: Button
|
||||
var host_label: Label
|
||||
var guest_label: Label
|
||||
var ready_button: Button
|
||||
var leave_room_button: Button
|
||||
var error_label: Label
|
||||
var nav_buttons_container: HBoxContainer
|
||||
var profile_button: Button
|
||||
var leaderboard_button: Button
|
||||
|
||||
# State
|
||||
var is_in_queue: bool = false
|
||||
var is_in_room: bool = false
|
||||
var is_ready: bool = false
|
||||
var queue_start_time: float = 0.0
|
||||
var current_room_code: String = ""
|
||||
var selected_deck_id: String = ""
|
||||
|
||||
# Styling
|
||||
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
|
||||
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
|
||||
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
|
||||
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
|
||||
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
|
||||
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_create_ui()
|
||||
_connect_network_signals()
|
||||
_update_user_info()
|
||||
_fetch_decks()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Update queue timer if searching
|
||||
if is_in_queue:
|
||||
var elapsed = Time.get_ticks_msec() / 1000.0 - queue_start_time
|
||||
var minutes = int(elapsed) / 60
|
||||
var seconds = int(elapsed) % 60
|
||||
queue_status_label.text = "Searching... %d:%02d" % [minutes, seconds]
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Background
|
||||
var bg = ColorRect.new()
|
||||
bg.color = BG_COLOR
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(bg)
|
||||
|
||||
# Main container
|
||||
main_container = VBoxContainer.new()
|
||||
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_theme_constant_override("separation", 16)
|
||||
add_child(main_container)
|
||||
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_right", 24)
|
||||
margin.add_theme_constant_override("margin_top", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 16)
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_child(margin)
|
||||
|
||||
var content = VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 16)
|
||||
margin.add_child(content)
|
||||
|
||||
# Back button
|
||||
back_button = _create_button("< Back", false)
|
||||
back_button.custom_minimum_size = Vector2(80, 32)
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
content.add_child(back_button)
|
||||
|
||||
# Header with username and ELO
|
||||
header_container = HBoxContainer.new()
|
||||
header_container.add_theme_constant_override("separation", 16)
|
||||
content.add_child(header_container)
|
||||
|
||||
username_label = Label.new()
|
||||
username_label.add_theme_font_override("font", custom_font)
|
||||
username_label.add_theme_font_size_override("font_size", 24)
|
||||
username_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
username_label.text = "Welcome!"
|
||||
username_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
header_container.add_child(username_label)
|
||||
|
||||
elo_label = Label.new()
|
||||
elo_label.add_theme_font_override("font", custom_font)
|
||||
elo_label.add_theme_font_size_override("font_size", 20)
|
||||
elo_label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
elo_label.text = "ELO: 1000"
|
||||
header_container.add_child(elo_label)
|
||||
|
||||
# Deck selection section
|
||||
deck_section = VBoxContainer.new()
|
||||
deck_section.add_theme_constant_override("separation", 8)
|
||||
content.add_child(deck_section)
|
||||
|
||||
var deck_label = Label.new()
|
||||
deck_label.add_theme_font_override("font", custom_font)
|
||||
deck_label.add_theme_font_size_override("font_size", 16)
|
||||
deck_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
deck_label.text = "SELECT DECK"
|
||||
deck_section.add_child(deck_label)
|
||||
|
||||
deck_dropdown = OptionButton.new()
|
||||
deck_dropdown.add_theme_font_override("font", custom_font)
|
||||
deck_dropdown.add_theme_font_size_override("font_size", 16)
|
||||
deck_dropdown.custom_minimum_size = Vector2(0, 40)
|
||||
deck_dropdown.item_selected.connect(_on_deck_selected)
|
||||
deck_section.add_child(deck_dropdown)
|
||||
|
||||
# Ranked match section
|
||||
ranked_section = _create_panel_section("RANKED MATCH")
|
||||
content.add_child(ranked_section)
|
||||
|
||||
ranked_content = ranked_section.get_child(0) as VBoxContainer
|
||||
|
||||
var ranked_desc = Label.new()
|
||||
ranked_desc.add_theme_font_override("font", custom_font)
|
||||
ranked_desc.add_theme_font_size_override("font_size", 14)
|
||||
ranked_desc.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
ranked_desc.text = "Find opponents near your skill level"
|
||||
ranked_content.add_child(ranked_desc)
|
||||
|
||||
find_match_button = _create_button("Find Match", true)
|
||||
find_match_button.pressed.connect(_on_find_match_pressed)
|
||||
ranked_content.add_child(find_match_button)
|
||||
|
||||
cancel_search_button = _create_button("Cancel Search", false)
|
||||
cancel_search_button.pressed.connect(_on_cancel_search_pressed)
|
||||
cancel_search_button.visible = false
|
||||
ranked_content.add_child(cancel_search_button)
|
||||
|
||||
queue_status_label = Label.new()
|
||||
queue_status_label.add_theme_font_override("font", custom_font)
|
||||
queue_status_label.add_theme_font_size_override("font_size", 14)
|
||||
queue_status_label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
queue_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
queue_status_label.visible = false
|
||||
ranked_content.add_child(queue_status_label)
|
||||
|
||||
# Private match section
|
||||
private_section = _create_panel_section("PRIVATE MATCH")
|
||||
content.add_child(private_section)
|
||||
|
||||
private_content = private_section.get_child(0) as VBoxContainer
|
||||
|
||||
create_room_button = _create_button("Create Room", true)
|
||||
create_room_button.pressed.connect(_on_create_room_pressed)
|
||||
private_content.add_child(create_room_button)
|
||||
|
||||
var separator_label = Label.new()
|
||||
separator_label.add_theme_font_override("font", custom_font)
|
||||
separator_label.add_theme_font_size_override("font_size", 12)
|
||||
separator_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
separator_label.text = "─────────── OR ───────────"
|
||||
separator_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
private_content.add_child(separator_label)
|
||||
|
||||
join_container = HBoxContainer.new()
|
||||
join_container.add_theme_constant_override("separation", 8)
|
||||
private_content.add_child(join_container)
|
||||
|
||||
var code_label = Label.new()
|
||||
code_label.add_theme_font_override("font", custom_font)
|
||||
code_label.add_theme_font_size_override("font_size", 14)
|
||||
code_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
code_label.text = "Code:"
|
||||
join_container.add_child(code_label)
|
||||
|
||||
room_code_input = LineEdit.new()
|
||||
room_code_input.add_theme_font_override("font", custom_font)
|
||||
room_code_input.add_theme_font_size_override("font_size", 16)
|
||||
room_code_input.placeholder_text = "ABC123"
|
||||
room_code_input.max_length = 6
|
||||
room_code_input.custom_minimum_size = Vector2(100, 36)
|
||||
room_code_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
room_code_input.text_changed.connect(_on_room_code_changed)
|
||||
join_container.add_child(room_code_input)
|
||||
|
||||
join_room_button = _create_button("Join", false)
|
||||
join_room_button.custom_minimum_size = Vector2(80, 36)
|
||||
join_room_button.pressed.connect(_on_join_room_pressed)
|
||||
join_container.add_child(join_room_button)
|
||||
|
||||
# Room section (shown when in a room)
|
||||
room_section = _create_panel_section("ROOM")
|
||||
room_section.visible = false
|
||||
content.add_child(room_section)
|
||||
|
||||
room_content = room_section.get_child(0) as VBoxContainer
|
||||
|
||||
var room_header = HBoxContainer.new()
|
||||
room_header.add_theme_constant_override("separation", 8)
|
||||
room_content.add_child(room_header)
|
||||
|
||||
room_code_label = Label.new()
|
||||
room_code_label.add_theme_font_override("font", custom_font)
|
||||
room_code_label.add_theme_font_size_override("font_size", 20)
|
||||
room_code_label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
room_code_label.text = "Room: ------"
|
||||
room_code_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
room_header.add_child(room_code_label)
|
||||
|
||||
copy_code_button = _create_button("Copy", false)
|
||||
copy_code_button.custom_minimum_size = Vector2(60, 28)
|
||||
copy_code_button.pressed.connect(_on_copy_code_pressed)
|
||||
room_header.add_child(copy_code_button)
|
||||
|
||||
var players_container = VBoxContainer.new()
|
||||
players_container.add_theme_constant_override("separation", 4)
|
||||
room_content.add_child(players_container)
|
||||
|
||||
host_label = Label.new()
|
||||
host_label.add_theme_font_override("font", custom_font)
|
||||
host_label.add_theme_font_size_override("font_size", 16)
|
||||
host_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
host_label.text = "Host: ---"
|
||||
players_container.add_child(host_label)
|
||||
|
||||
guest_label = Label.new()
|
||||
guest_label.add_theme_font_override("font", custom_font)
|
||||
guest_label.add_theme_font_size_override("font_size", 16)
|
||||
guest_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
guest_label.text = "Guest: Waiting..."
|
||||
players_container.add_child(guest_label)
|
||||
|
||||
var room_buttons = HBoxContainer.new()
|
||||
room_buttons.add_theme_constant_override("separation", 8)
|
||||
room_content.add_child(room_buttons)
|
||||
|
||||
ready_button = _create_button("Ready", true)
|
||||
ready_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
ready_button.pressed.connect(_on_ready_pressed)
|
||||
room_buttons.add_child(ready_button)
|
||||
|
||||
leave_room_button = _create_button("Leave Room", false)
|
||||
leave_room_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
leave_room_button.pressed.connect(_on_leave_room_pressed)
|
||||
room_buttons.add_child(leave_room_button)
|
||||
|
||||
# Error label
|
||||
error_label = Label.new()
|
||||
error_label.add_theme_font_override("font", custom_font)
|
||||
error_label.add_theme_font_size_override("font_size", 14)
|
||||
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
error_label.visible = false
|
||||
content.add_child(error_label)
|
||||
|
||||
# Navigation buttons (Profile and Leaderboard)
|
||||
nav_buttons_container = HBoxContainer.new()
|
||||
nav_buttons_container.add_theme_constant_override("separation", 16)
|
||||
nav_buttons_container.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_child(nav_buttons_container)
|
||||
|
||||
profile_button = _create_button("Profile", false)
|
||||
profile_button.custom_minimum_size = Vector2(120, 36)
|
||||
profile_button.pressed.connect(_on_profile_pressed)
|
||||
nav_buttons_container.add_child(profile_button)
|
||||
|
||||
leaderboard_button = _create_button("Leaderboard", false)
|
||||
leaderboard_button.custom_minimum_size = Vector2(120, 36)
|
||||
leaderboard_button.pressed.connect(_on_leaderboard_pressed)
|
||||
nav_buttons_container.add_child(leaderboard_button)
|
||||
|
||||
|
||||
func _create_panel_section(title: String) -> PanelContainer:
|
||||
var panel = PanelContainer.new()
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = PANEL_COLOR
|
||||
style.set_corner_radius_all(8)
|
||||
style.set_content_margin_all(16)
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(vbox)
|
||||
|
||||
var title_label = Label.new()
|
||||
title_label.add_theme_font_override("font", custom_font)
|
||||
title_label.add_theme_font_size_override("font_size", 18)
|
||||
title_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title_label.text = title
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title_label)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _create_button(text: String, primary: bool) -> Button:
|
||||
var button = Button.new()
|
||||
button.text = text
|
||||
button.add_theme_font_override("font", custom_font)
|
||||
button.add_theme_font_size_override("font_size", 16)
|
||||
button.custom_minimum_size = Vector2(0, 40)
|
||||
|
||||
var normal = StyleBoxFlat.new()
|
||||
var hover = StyleBoxFlat.new()
|
||||
var pressed = StyleBoxFlat.new()
|
||||
var disabled = StyleBoxFlat.new()
|
||||
|
||||
if primary:
|
||||
normal.bg_color = ACCENT_COLOR
|
||||
hover.bg_color = ACCENT_COLOR.lightened(0.15)
|
||||
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
else:
|
||||
normal.bg_color = Color(0.25, 0.23, 0.3)
|
||||
hover.bg_color = Color(0.3, 0.28, 0.35)
|
||||
pressed.bg_color = Color(0.2, 0.18, 0.25)
|
||||
button.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
|
||||
disabled.bg_color = Color(0.2, 0.18, 0.22)
|
||||
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
|
||||
|
||||
for style in [normal, hover, pressed, disabled]:
|
||||
style.set_corner_radius_all(6)
|
||||
style.set_content_margin_all(8)
|
||||
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", pressed)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
|
||||
return button
|
||||
|
||||
|
||||
func _connect_network_signals() -> void:
|
||||
NetworkManager.queue_joined.connect(_on_queue_joined)
|
||||
NetworkManager.queue_left.connect(_on_queue_left)
|
||||
NetworkManager.room_created.connect(_on_room_created)
|
||||
NetworkManager.room_joined.connect(_on_room_joined)
|
||||
NetworkManager.room_updated.connect(_on_room_updated)
|
||||
NetworkManager.matchmaking_update.connect(_on_matchmaking_update)
|
||||
NetworkManager.match_found.connect(_on_match_found)
|
||||
NetworkManager.network_error.connect(_on_network_error)
|
||||
|
||||
|
||||
func _disconnect_network_signals() -> void:
|
||||
if NetworkManager.queue_joined.is_connected(_on_queue_joined):
|
||||
NetworkManager.queue_joined.disconnect(_on_queue_joined)
|
||||
if NetworkManager.queue_left.is_connected(_on_queue_left):
|
||||
NetworkManager.queue_left.disconnect(_on_queue_left)
|
||||
if NetworkManager.room_created.is_connected(_on_room_created):
|
||||
NetworkManager.room_created.disconnect(_on_room_created)
|
||||
if NetworkManager.room_joined.is_connected(_on_room_joined):
|
||||
NetworkManager.room_joined.disconnect(_on_room_joined)
|
||||
if NetworkManager.room_updated.is_connected(_on_room_updated):
|
||||
NetworkManager.room_updated.disconnect(_on_room_updated)
|
||||
if NetworkManager.matchmaking_update.is_connected(_on_matchmaking_update):
|
||||
NetworkManager.matchmaking_update.disconnect(_on_matchmaking_update)
|
||||
if NetworkManager.match_found.is_connected(_on_match_found):
|
||||
NetworkManager.match_found.disconnect(_on_match_found)
|
||||
if NetworkManager.network_error.is_connected(_on_network_error):
|
||||
NetworkManager.network_error.disconnect(_on_network_error)
|
||||
|
||||
|
||||
func _update_user_info() -> void:
|
||||
var user = NetworkManager.current_user
|
||||
if user.has("username"):
|
||||
username_label.text = "Welcome, %s!" % user.username
|
||||
if user.has("stats") and user.stats.has("elo_rating"):
|
||||
elo_label.text = "ELO: %d" % user.stats.elo_rating
|
||||
else:
|
||||
elo_label.text = "ELO: 1000"
|
||||
|
||||
|
||||
func _fetch_decks() -> void:
|
||||
deck_dropdown.clear()
|
||||
deck_dropdown.add_item("-- Select a Deck --", 0)
|
||||
|
||||
# Add decks from user profile
|
||||
var user = NetworkManager.current_user
|
||||
if user.has("decks") and user.decks is Array:
|
||||
for i in range(user.decks.size()):
|
||||
var deck = user.decks[i]
|
||||
deck_dropdown.add_item(deck.name, i + 1)
|
||||
deck_dropdown.set_item_metadata(i + 1, deck.id)
|
||||
|
||||
# Also add local starter decks as fallback
|
||||
if deck_dropdown.item_count <= 1:
|
||||
var starter_decks = _get_local_decks()
|
||||
for i in range(starter_decks.size()):
|
||||
deck_dropdown.add_item(starter_decks[i].name, i + 1)
|
||||
deck_dropdown.set_item_metadata(i + 1, "local_%d" % i)
|
||||
|
||||
|
||||
func _get_local_decks() -> Array:
|
||||
# Load starter decks from local file
|
||||
var decks = []
|
||||
var file_path = "res://data/starter_decks.json"
|
||||
if FileAccess.file_exists(file_path):
|
||||
var file = FileAccess.open(file_path, FileAccess.READ)
|
||||
if file:
|
||||
var json = JSON.new()
|
||||
var result = json.parse(file.get_as_text())
|
||||
if result == OK and json.data is Dictionary:
|
||||
if json.data.has("decks"):
|
||||
decks = json.data.decks
|
||||
file.close()
|
||||
return decks
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
# Auto-hide after 5 seconds
|
||||
await get_tree().create_timer(5.0).timeout
|
||||
if is_instance_valid(error_label):
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
func _update_ui_state() -> void:
|
||||
# Update button states based on current state
|
||||
var has_deck = selected_deck_id != ""
|
||||
|
||||
# Queue UI
|
||||
find_match_button.visible = not is_in_queue and not is_in_room
|
||||
find_match_button.disabled = not has_deck
|
||||
cancel_search_button.visible = is_in_queue
|
||||
queue_status_label.visible = is_in_queue
|
||||
|
||||
# Private match UI
|
||||
private_section.visible = not is_in_queue and not is_in_room
|
||||
create_room_button.disabled = not has_deck
|
||||
join_room_button.disabled = not has_deck or room_code_input.text.length() != 6
|
||||
|
||||
# Room UI
|
||||
room_section.visible = is_in_room
|
||||
|
||||
# Disable ranked section when in room
|
||||
ranked_section.visible = not is_in_room
|
||||
|
||||
|
||||
# ========== BUTTON HANDLERS ==========
|
||||
|
||||
func _on_profile_pressed() -> void:
|
||||
profile_requested.emit()
|
||||
|
||||
|
||||
func _on_leaderboard_pressed() -> void:
|
||||
leaderboard_requested.emit()
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
# Leave queue or room before going back
|
||||
if is_in_queue:
|
||||
NetworkManager.leave_queue()
|
||||
if is_in_room:
|
||||
NetworkManager.leave_room()
|
||||
_disconnect_network_signals()
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
func _on_deck_selected(index: int) -> void:
|
||||
if index == 0:
|
||||
selected_deck_id = ""
|
||||
else:
|
||||
selected_deck_id = str(deck_dropdown.get_item_metadata(index))
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_room_code_changed(_new_text: String) -> void:
|
||||
room_code_input.text = room_code_input.text.to_upper()
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_find_match_pressed() -> void:
|
||||
if selected_deck_id == "":
|
||||
_show_error("Please select a deck first")
|
||||
return
|
||||
|
||||
# Connect to WebSocket if not connected
|
||||
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
|
||||
NetworkManager.connect_websocket()
|
||||
await NetworkManager.connection_state_changed
|
||||
# Wait a bit for auth
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
NetworkManager.join_queue(selected_deck_id)
|
||||
|
||||
|
||||
func _on_cancel_search_pressed() -> void:
|
||||
NetworkManager.leave_queue()
|
||||
|
||||
|
||||
func _on_create_room_pressed() -> void:
|
||||
if selected_deck_id == "":
|
||||
_show_error("Please select a deck first")
|
||||
return
|
||||
|
||||
# Connect to WebSocket if not connected
|
||||
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
|
||||
NetworkManager.connect_websocket()
|
||||
await NetworkManager.connection_state_changed
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
NetworkManager.create_room(selected_deck_id)
|
||||
|
||||
|
||||
func _on_join_room_pressed() -> void:
|
||||
var code = room_code_input.text.strip_edges().to_upper()
|
||||
if code.length() != 6:
|
||||
_show_error("Room code must be 6 characters")
|
||||
return
|
||||
|
||||
if selected_deck_id == "":
|
||||
_show_error("Please select a deck first")
|
||||
return
|
||||
|
||||
# Connect to WebSocket if not connected
|
||||
if NetworkManager.connection_state < NetworkManager.ConnectionState.CONNECTED:
|
||||
NetworkManager.connect_websocket()
|
||||
await NetworkManager.connection_state_changed
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
NetworkManager.join_room(code, selected_deck_id)
|
||||
|
||||
|
||||
func _on_copy_code_pressed() -> void:
|
||||
DisplayServer.clipboard_set(current_room_code)
|
||||
copy_code_button.text = "Copied!"
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
if is_instance_valid(copy_code_button):
|
||||
copy_code_button.text = "Copy"
|
||||
|
||||
|
||||
func _on_ready_pressed() -> void:
|
||||
is_ready = not is_ready
|
||||
ready_button.text = "Not Ready" if is_ready else "Ready"
|
||||
NetworkManager.set_room_ready(is_ready)
|
||||
|
||||
|
||||
func _on_leave_room_pressed() -> void:
|
||||
NetworkManager.leave_room()
|
||||
|
||||
|
||||
# ========== NETWORK SIGNAL HANDLERS ==========
|
||||
|
||||
func _on_queue_joined() -> void:
|
||||
is_in_queue = true
|
||||
queue_start_time = Time.get_ticks_msec() / 1000.0
|
||||
queue_status_label.text = "Searching... 0:00"
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_queue_left() -> void:
|
||||
is_in_queue = false
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_room_created(room_data: Dictionary) -> void:
|
||||
is_in_room = true
|
||||
current_room_code = room_data.get("code", "")
|
||||
_update_room_display(room_data)
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_room_joined(room_data: Dictionary) -> void:
|
||||
is_in_room = true
|
||||
current_room_code = room_data.get("code", "")
|
||||
_update_room_display(room_data)
|
||||
_update_ui_state()
|
||||
|
||||
|
||||
func _on_room_updated(room_data: Dictionary) -> void:
|
||||
_update_room_display(room_data)
|
||||
|
||||
|
||||
func _update_room_display(room_data: Dictionary) -> void:
|
||||
room_code_label.text = "Room: %s" % room_data.get("code", "------")
|
||||
|
||||
var host = room_data.get("host", {})
|
||||
var host_ready = " ✓" if host.get("ready", false) else ""
|
||||
host_label.text = "Host: %s%s" % [host.get("username", "---"), host_ready]
|
||||
|
||||
var guest = room_data.get("guest", null)
|
||||
if guest:
|
||||
var guest_ready = " ✓" if guest.get("ready", false) else ""
|
||||
guest_label.text = "Guest: %s%s" % [guest.get("username", "---"), guest_ready]
|
||||
ready_button.disabled = false
|
||||
else:
|
||||
guest_label.text = "Guest: Waiting for opponent..."
|
||||
ready_button.disabled = true
|
||||
|
||||
|
||||
func _on_matchmaking_update(data: Dictionary) -> void:
|
||||
var update_type = data.get("type", "")
|
||||
match update_type:
|
||||
"queue_left":
|
||||
is_in_queue = false
|
||||
_update_ui_state()
|
||||
"room_left":
|
||||
is_in_room = false
|
||||
is_ready = false
|
||||
ready_button.text = "Ready"
|
||||
current_room_code = ""
|
||||
room_code_input.text = ""
|
||||
_update_ui_state()
|
||||
var reason = data.get("reason", "")
|
||||
if reason != "":
|
||||
_show_error(reason)
|
||||
|
||||
|
||||
func _on_match_found(game_data: Dictionary) -> void:
|
||||
print("Match found! Game ID: ", game_data.get("game_id", ""))
|
||||
is_in_queue = false
|
||||
is_in_room = false
|
||||
_disconnect_network_signals()
|
||||
game_starting.emit(game_data)
|
||||
|
||||
|
||||
func _on_network_error(error: String) -> void:
|
||||
_show_error(error)
|
||||
|
||||
|
||||
# ========== CLEANUP ==========
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_disconnect_network_signals()
|
||||
494
scripts/ui/ProfileScreen.gd
Normal file
494
scripts/ui/ProfileScreen.gd
Normal file
@@ -0,0 +1,494 @@
|
||||
class_name ProfileScreen
|
||||
extends CanvasLayer
|
||||
|
||||
## ProfileScreen - Displays player stats and match history
|
||||
|
||||
signal back_pressed
|
||||
|
||||
# Window dimensions
|
||||
const WINDOW_SIZE := Vector2i(600, 700)
|
||||
|
||||
# Pagination
|
||||
const MATCHES_PER_PAGE = 10
|
||||
|
||||
# UI Components
|
||||
var main_container: VBoxContainer
|
||||
var back_button: Button
|
||||
var username_label: Label
|
||||
var stats_container: HBoxContainer
|
||||
var elo_value: Label
|
||||
var wins_value: Label
|
||||
var losses_value: Label
|
||||
var winrate_value: Label
|
||||
var games_value: Label
|
||||
var history_section: PanelContainer
|
||||
var history_list: VBoxContainer
|
||||
var pagination_container: HBoxContainer
|
||||
var prev_button: Button
|
||||
var page_label: Label
|
||||
var next_button: Button
|
||||
var loading_label: Label
|
||||
var error_label: Label
|
||||
|
||||
# State
|
||||
var current_page: int = 0
|
||||
var total_matches: int = 0
|
||||
var is_loading: bool = false
|
||||
var matches_cache: Array = []
|
||||
|
||||
# Styling
|
||||
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
|
||||
const BG_COLOR := Color(0.12, 0.11, 0.15, 1.0)
|
||||
const PANEL_COLOR := Color(0.18, 0.16, 0.22, 1.0)
|
||||
const ACCENT_COLOR := Color(0.4, 0.35, 0.55, 1.0)
|
||||
const TEXT_COLOR := Color(0.9, 0.88, 0.82, 1.0)
|
||||
const MUTED_COLOR := Color(0.6, 0.58, 0.52, 1.0)
|
||||
const WIN_COLOR := Color(0.4, 0.8, 0.4, 1.0)
|
||||
const LOSS_COLOR := Color(0.8, 0.4, 0.4, 1.0)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_create_ui()
|
||||
_load_profile()
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Background
|
||||
var bg = ColorRect.new()
|
||||
bg.color = BG_COLOR
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(bg)
|
||||
|
||||
# Main container
|
||||
main_container = VBoxContainer.new()
|
||||
main_container.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_theme_constant_override("separation", 16)
|
||||
add_child(main_container)
|
||||
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_right", 24)
|
||||
margin.add_theme_constant_override("margin_top", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 16)
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
main_container.add_child(margin)
|
||||
|
||||
var content = VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 16)
|
||||
margin.add_child(content)
|
||||
|
||||
# Back button
|
||||
back_button = _create_button("< Back", false)
|
||||
back_button.custom_minimum_size = Vector2(80, 32)
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
content.add_child(back_button)
|
||||
|
||||
# Title
|
||||
var title = Label.new()
|
||||
title.add_theme_font_override("font", custom_font)
|
||||
title.add_theme_font_size_override("font_size", 28)
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title.text = "PLAYER PROFILE"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(title)
|
||||
|
||||
# Username
|
||||
username_label = Label.new()
|
||||
username_label.add_theme_font_override("font", custom_font)
|
||||
username_label.add_theme_font_size_override("font_size", 22)
|
||||
username_label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
username_label.text = "Loading..."
|
||||
username_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(username_label)
|
||||
|
||||
# Stats section
|
||||
_create_stats_section(content)
|
||||
|
||||
# Match history section
|
||||
_create_history_section(content)
|
||||
|
||||
# Error label
|
||||
error_label = Label.new()
|
||||
error_label.add_theme_font_override("font", custom_font)
|
||||
error_label.add_theme_font_size_override("font_size", 14)
|
||||
error_label.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
error_label.visible = false
|
||||
content.add_child(error_label)
|
||||
|
||||
|
||||
func _create_stats_section(parent: VBoxContainer) -> void:
|
||||
var stats_panel = _create_panel_section("STATISTICS")
|
||||
parent.add_child(stats_panel)
|
||||
|
||||
stats_container = HBoxContainer.new()
|
||||
stats_container.add_theme_constant_override("separation", 24)
|
||||
stats_panel.get_child(0).add_child(stats_container)
|
||||
|
||||
# Create stat boxes
|
||||
var elo_box = _create_stat_box("ELO RATING")
|
||||
elo_value = elo_box.get_child(1)
|
||||
stats_container.add_child(elo_box)
|
||||
|
||||
var wins_box = _create_stat_box("WINS")
|
||||
wins_value = wins_box.get_child(1)
|
||||
stats_container.add_child(wins_box)
|
||||
|
||||
var losses_box = _create_stat_box("LOSSES")
|
||||
losses_value = losses_box.get_child(1)
|
||||
stats_container.add_child(losses_box)
|
||||
|
||||
var winrate_box = _create_stat_box("WIN RATE")
|
||||
winrate_value = winrate_box.get_child(1)
|
||||
stats_container.add_child(winrate_box)
|
||||
|
||||
var games_box = _create_stat_box("GAMES")
|
||||
games_value = games_box.get_child(1)
|
||||
stats_container.add_child(games_box)
|
||||
|
||||
|
||||
func _create_stat_box(title: String) -> VBoxContainer:
|
||||
var box = VBoxContainer.new()
|
||||
box.add_theme_constant_override("separation", 4)
|
||||
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var title_label = Label.new()
|
||||
title_label.add_theme_font_override("font", custom_font)
|
||||
title_label.add_theme_font_size_override("font_size", 12)
|
||||
title_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
title_label.text = title
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(title_label)
|
||||
|
||||
var value_label = Label.new()
|
||||
value_label.add_theme_font_override("font", custom_font)
|
||||
value_label.add_theme_font_size_override("font_size", 24)
|
||||
value_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
value_label.text = "-"
|
||||
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(value_label)
|
||||
|
||||
return box
|
||||
|
||||
|
||||
func _create_history_section(parent: VBoxContainer) -> void:
|
||||
history_section = _create_panel_section("MATCH HISTORY")
|
||||
history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(history_section)
|
||||
|
||||
var content = history_section.get_child(0) as VBoxContainer
|
||||
|
||||
# Loading indicator
|
||||
loading_label = Label.new()
|
||||
loading_label.add_theme_font_override("font", custom_font)
|
||||
loading_label.add_theme_font_size_override("font_size", 14)
|
||||
loading_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
loading_label.text = "Loading..."
|
||||
loading_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(loading_label)
|
||||
|
||||
# Match list
|
||||
history_list = VBoxContainer.new()
|
||||
history_list.add_theme_constant_override("separation", 8)
|
||||
history_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content.add_child(history_list)
|
||||
|
||||
# Pagination
|
||||
pagination_container = HBoxContainer.new()
|
||||
pagination_container.add_theme_constant_override("separation", 16)
|
||||
pagination_container.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_child(pagination_container)
|
||||
|
||||
prev_button = _create_button("< Prev", false)
|
||||
prev_button.custom_minimum_size = Vector2(80, 32)
|
||||
prev_button.pressed.connect(_on_prev_page)
|
||||
pagination_container.add_child(prev_button)
|
||||
|
||||
page_label = Label.new()
|
||||
page_label.add_theme_font_override("font", custom_font)
|
||||
page_label.add_theme_font_size_override("font_size", 14)
|
||||
page_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
page_label.text = "Page 1"
|
||||
pagination_container.add_child(page_label)
|
||||
|
||||
next_button = _create_button("Next >", false)
|
||||
next_button.custom_minimum_size = Vector2(80, 32)
|
||||
next_button.pressed.connect(_on_next_page)
|
||||
pagination_container.add_child(next_button)
|
||||
|
||||
|
||||
func _create_panel_section(title: String) -> PanelContainer:
|
||||
var panel = PanelContainer.new()
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = PANEL_COLOR
|
||||
style.set_corner_radius_all(8)
|
||||
style.set_content_margin_all(16)
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox = VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(vbox)
|
||||
|
||||
var title_label = Label.new()
|
||||
title_label.add_theme_font_override("font", custom_font)
|
||||
title_label.add_theme_font_size_override("font_size", 18)
|
||||
title_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title_label.text = title
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title_label)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _create_button(text: String, primary: bool) -> Button:
|
||||
var button = Button.new()
|
||||
button.text = text
|
||||
button.add_theme_font_override("font", custom_font)
|
||||
button.add_theme_font_size_override("font_size", 16)
|
||||
button.custom_minimum_size = Vector2(0, 40)
|
||||
|
||||
var normal = StyleBoxFlat.new()
|
||||
var hover = StyleBoxFlat.new()
|
||||
var pressed = StyleBoxFlat.new()
|
||||
var disabled = StyleBoxFlat.new()
|
||||
|
||||
if primary:
|
||||
normal.bg_color = ACCENT_COLOR
|
||||
hover.bg_color = ACCENT_COLOR.lightened(0.15)
|
||||
pressed.bg_color = ACCENT_COLOR.darkened(0.15)
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
else:
|
||||
normal.bg_color = Color(0.25, 0.23, 0.3)
|
||||
hover.bg_color = Color(0.3, 0.28, 0.35)
|
||||
pressed.bg_color = Color(0.2, 0.18, 0.25)
|
||||
button.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
button.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
|
||||
disabled.bg_color = Color(0.2, 0.18, 0.22)
|
||||
button.add_theme_color_override("font_disabled_color", MUTED_COLOR)
|
||||
|
||||
for style in [normal, hover, pressed, disabled]:
|
||||
style.set_corner_radius_all(6)
|
||||
style.set_content_margin_all(8)
|
||||
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", pressed)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
|
||||
return button
|
||||
|
||||
|
||||
func _load_profile() -> void:
|
||||
if not NetworkManager or not NetworkManager.is_authenticated:
|
||||
_show_error("Not logged in")
|
||||
return
|
||||
|
||||
is_loading = true
|
||||
loading_label.visible = true
|
||||
|
||||
var result = await NetworkManager.get_profile()
|
||||
|
||||
if result.success:
|
||||
_update_profile_display(result.user)
|
||||
_load_match_history()
|
||||
else:
|
||||
_show_error(result.message)
|
||||
|
||||
is_loading = false
|
||||
|
||||
|
||||
func _update_profile_display(user: Dictionary) -> void:
|
||||
username_label.text = user.get("username", "Unknown")
|
||||
|
||||
var stats = user.get("stats", {})
|
||||
elo_value.text = str(stats.get("eloRating", 1000))
|
||||
wins_value.text = str(stats.get("wins", 0))
|
||||
losses_value.text = str(stats.get("losses", 0))
|
||||
games_value.text = str(stats.get("gamesPlayed", 0))
|
||||
|
||||
# Calculate win rate
|
||||
var games_played = stats.get("gamesPlayed", 0)
|
||||
if games_played > 0:
|
||||
var win_rate = float(stats.get("wins", 0)) / float(games_played) * 100.0
|
||||
winrate_value.text = "%.1f%%" % win_rate
|
||||
else:
|
||||
winrate_value.text = "N/A"
|
||||
|
||||
|
||||
func _load_match_history() -> void:
|
||||
loading_label.visible = true
|
||||
_clear_history_list()
|
||||
|
||||
var offset = current_page * MATCHES_PER_PAGE
|
||||
var result = await NetworkManager.get_match_history(MATCHES_PER_PAGE, offset)
|
||||
|
||||
loading_label.visible = false
|
||||
|
||||
if result.success:
|
||||
var matches = result.get("matches", [])
|
||||
matches_cache = matches
|
||||
_display_matches(matches)
|
||||
_update_pagination()
|
||||
else:
|
||||
_show_error(result.message)
|
||||
|
||||
|
||||
func _display_matches(matches: Array) -> void:
|
||||
_clear_history_list()
|
||||
|
||||
if matches.is_empty():
|
||||
var empty_label = Label.new()
|
||||
empty_label.add_theme_font_override("font", custom_font)
|
||||
empty_label.add_theme_font_size_override("font_size", 14)
|
||||
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
empty_label.text = "No matches found"
|
||||
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
history_list.add_child(empty_label)
|
||||
return
|
||||
|
||||
for match_data in matches:
|
||||
var match_row = _create_match_row(match_data)
|
||||
history_list.add_child(match_row)
|
||||
|
||||
|
||||
func _create_match_row(match_data: Dictionary) -> HBoxContainer:
|
||||
var row = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
|
||||
# Win/Loss indicator
|
||||
var result_label = Label.new()
|
||||
result_label.add_theme_font_override("font", custom_font)
|
||||
result_label.add_theme_font_size_override("font_size", 14)
|
||||
result_label.custom_minimum_size.x = 50
|
||||
|
||||
var is_win = match_data.get("isWin", false)
|
||||
if is_win:
|
||||
result_label.text = "WIN"
|
||||
result_label.add_theme_color_override("font_color", WIN_COLOR)
|
||||
else:
|
||||
result_label.text = "LOSS"
|
||||
result_label.add_theme_color_override("font_color", LOSS_COLOR)
|
||||
row.add_child(result_label)
|
||||
|
||||
# Opponent
|
||||
var vs_label = Label.new()
|
||||
vs_label.add_theme_font_override("font", custom_font)
|
||||
vs_label.add_theme_font_size_override("font_size", 14)
|
||||
vs_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
vs_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var opponent = match_data.get("player1", "")
|
||||
var current_username = NetworkManager.current_user.get("username", "")
|
||||
if opponent == current_username:
|
||||
opponent = match_data.get("player2", "Unknown")
|
||||
vs_label.text = "vs %s" % opponent
|
||||
row.add_child(vs_label)
|
||||
|
||||
# Result type
|
||||
var reason_label = Label.new()
|
||||
reason_label.add_theme_font_override("font", custom_font)
|
||||
reason_label.add_theme_font_size_override("font_size", 12)
|
||||
reason_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
reason_label.custom_minimum_size.x = 80
|
||||
|
||||
var result_reason = match_data.get("result", "")
|
||||
match result_reason:
|
||||
"damage":
|
||||
reason_label.text = "Damage"
|
||||
"deck_out":
|
||||
reason_label.text = "Deck Out"
|
||||
"concede":
|
||||
reason_label.text = "Concede"
|
||||
"timeout":
|
||||
reason_label.text = "Timeout"
|
||||
"disconnect":
|
||||
reason_label.text = "Disconnect"
|
||||
_:
|
||||
reason_label.text = result_reason.capitalize()
|
||||
row.add_child(reason_label)
|
||||
|
||||
# ELO change
|
||||
var elo_label = Label.new()
|
||||
elo_label.add_theme_font_override("font", custom_font)
|
||||
elo_label.add_theme_font_size_override("font_size", 14)
|
||||
elo_label.custom_minimum_size.x = 60
|
||||
elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
|
||||
var elo_change = match_data.get("eloChange", 0)
|
||||
if is_win:
|
||||
elo_label.text = "+%d" % elo_change
|
||||
elo_label.add_theme_color_override("font_color", WIN_COLOR)
|
||||
else:
|
||||
elo_label.text = "-%d" % elo_change
|
||||
elo_label.add_theme_color_override("font_color", LOSS_COLOR)
|
||||
row.add_child(elo_label)
|
||||
|
||||
# Date
|
||||
var date_label = Label.new()
|
||||
date_label.add_theme_font_override("font", custom_font)
|
||||
date_label.add_theme_font_size_override("font_size", 12)
|
||||
date_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
date_label.custom_minimum_size.x = 80
|
||||
date_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
|
||||
var played_at = match_data.get("playedAt", "")
|
||||
if played_at != "":
|
||||
# Parse ISO date and format nicely
|
||||
date_label.text = _format_date(played_at)
|
||||
row.add_child(date_label)
|
||||
|
||||
return row
|
||||
|
||||
|
||||
func _format_date(iso_date: String) -> String:
|
||||
# Simple date formatting - extracts date portion
|
||||
if iso_date.contains("T"):
|
||||
var parts = iso_date.split("T")
|
||||
var date_part = parts[0]
|
||||
var date_components = date_part.split("-")
|
||||
if date_components.size() >= 3:
|
||||
return "%s/%s" % [date_components[1], date_components[2]]
|
||||
return iso_date.left(10)
|
||||
|
||||
|
||||
func _clear_history_list() -> void:
|
||||
for child in history_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
func _update_pagination() -> void:
|
||||
page_label.text = "Page %d" % (current_page + 1)
|
||||
prev_button.disabled = current_page == 0
|
||||
# Disable next if we got fewer results than requested (end of data)
|
||||
next_button.disabled = matches_cache.size() < MATCHES_PER_PAGE
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
await get_tree().create_timer(5.0).timeout
|
||||
if is_instance_valid(error_label):
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
func _on_prev_page() -> void:
|
||||
if current_page > 0:
|
||||
current_page -= 1
|
||||
_load_match_history()
|
||||
|
||||
|
||||
func _on_next_page() -> void:
|
||||
current_page += 1
|
||||
_load_match_history()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
current_page = 0
|
||||
_load_profile()
|
||||
431
scripts/ui/RegisterScreen.gd
Normal file
431
scripts/ui/RegisterScreen.gd
Normal file
@@ -0,0 +1,431 @@
|
||||
class_name RegisterScreen
|
||||
extends CanvasLayer
|
||||
|
||||
## RegisterScreen - Account creation form for online play
|
||||
|
||||
signal registration_successful(message: String)
|
||||
signal login_requested
|
||||
signal back_pressed
|
||||
|
||||
const WINDOW_SIZE := Vector2(400, 600)
|
||||
|
||||
# Validation constants
|
||||
const USERNAME_MIN_LENGTH = 3
|
||||
const USERNAME_MAX_LENGTH = 32
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
|
||||
# UI Components
|
||||
var background: PanelContainer
|
||||
var main_vbox: VBoxContainer
|
||||
var title_label: Label
|
||||
var email_input: LineEdit
|
||||
var username_input: LineEdit
|
||||
var password_input: LineEdit
|
||||
var confirm_password_input: LineEdit
|
||||
var register_button: Button
|
||||
var login_link: Button
|
||||
var back_button: Button
|
||||
var error_label: Label
|
||||
var success_label: Label
|
||||
|
||||
# State
|
||||
var _is_loading: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 100
|
||||
_create_ui()
|
||||
|
||||
|
||||
func _create_ui() -> void:
|
||||
# Background panel
|
||||
background = PanelContainer.new()
|
||||
add_child(background)
|
||||
background.position = Vector2.ZERO
|
||||
background.size = WINDOW_SIZE
|
||||
background.add_theme_stylebox_override("panel", _create_panel_style())
|
||||
|
||||
# Main layout with margin
|
||||
var margin = MarginContainer.new()
|
||||
background.add_child(margin)
|
||||
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 40)
|
||||
margin.add_theme_constant_override("margin_right", 40)
|
||||
margin.add_theme_constant_override("margin_top", 25)
|
||||
margin.add_theme_constant_override("margin_bottom", 25)
|
||||
|
||||
main_vbox = VBoxContainer.new()
|
||||
margin.add_child(main_vbox)
|
||||
main_vbox.add_theme_constant_override("separation", 10)
|
||||
|
||||
# Title
|
||||
_create_title()
|
||||
|
||||
# Registration form
|
||||
_create_form()
|
||||
|
||||
# Messages
|
||||
_create_message_labels()
|
||||
|
||||
# Spacer
|
||||
var spacer = Control.new()
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
main_vbox.add_child(spacer)
|
||||
|
||||
# Links
|
||||
_create_links()
|
||||
|
||||
# Back button
|
||||
_create_back_button()
|
||||
|
||||
|
||||
func _create_title() -> void:
|
||||
title_label = Label.new()
|
||||
title_label.text = "CREATE ACCOUNT"
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title_label.add_theme_font_size_override("font_size", 28)
|
||||
title_label.add_theme_color_override("font_color", Color(1.0, 0.95, 0.8))
|
||||
main_vbox.add_child(title_label)
|
||||
|
||||
# Separator
|
||||
var separator = HSeparator.new()
|
||||
separator.add_theme_stylebox_override("separator", _create_separator_style())
|
||||
main_vbox.add_child(separator)
|
||||
|
||||
# Small spacer
|
||||
var spacer = Control.new()
|
||||
spacer.custom_minimum_size.y = 10
|
||||
main_vbox.add_child(spacer)
|
||||
|
||||
|
||||
func _create_form() -> void:
|
||||
# Email field
|
||||
var email_label = Label.new()
|
||||
email_label.text = "Email"
|
||||
email_label.add_theme_font_size_override("font_size", 15)
|
||||
email_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(email_label)
|
||||
|
||||
email_input = LineEdit.new()
|
||||
email_input.placeholder_text = "Enter your email"
|
||||
email_input.custom_minimum_size = Vector2(0, 38)
|
||||
_style_input(email_input)
|
||||
main_vbox.add_child(email_input)
|
||||
|
||||
# Username field
|
||||
var username_label = Label.new()
|
||||
username_label.text = "Username"
|
||||
username_label.add_theme_font_size_override("font_size", 15)
|
||||
username_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(username_label)
|
||||
|
||||
username_input = LineEdit.new()
|
||||
username_input.placeholder_text = "3-32 characters"
|
||||
username_input.custom_minimum_size = Vector2(0, 38)
|
||||
username_input.max_length = USERNAME_MAX_LENGTH
|
||||
_style_input(username_input)
|
||||
main_vbox.add_child(username_input)
|
||||
|
||||
# Password field
|
||||
var password_label = Label.new()
|
||||
password_label.text = "Password"
|
||||
password_label.add_theme_font_size_override("font_size", 15)
|
||||
password_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(password_label)
|
||||
|
||||
password_input = LineEdit.new()
|
||||
password_input.placeholder_text = "At least 8 characters"
|
||||
password_input.secret = true
|
||||
password_input.custom_minimum_size = Vector2(0, 38)
|
||||
_style_input(password_input)
|
||||
main_vbox.add_child(password_input)
|
||||
|
||||
# Confirm password field
|
||||
var confirm_label = Label.new()
|
||||
confirm_label.text = "Confirm Password"
|
||||
confirm_label.add_theme_font_size_override("font_size", 15)
|
||||
confirm_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
main_vbox.add_child(confirm_label)
|
||||
|
||||
confirm_password_input = LineEdit.new()
|
||||
confirm_password_input.placeholder_text = "Re-enter your password"
|
||||
confirm_password_input.secret = true
|
||||
confirm_password_input.custom_minimum_size = Vector2(0, 38)
|
||||
_style_input(confirm_password_input)
|
||||
confirm_password_input.text_submitted.connect(_on_input_submitted)
|
||||
main_vbox.add_child(confirm_password_input)
|
||||
|
||||
# Register button
|
||||
var button_spacer = Control.new()
|
||||
button_spacer.custom_minimum_size.y = 10
|
||||
main_vbox.add_child(button_spacer)
|
||||
|
||||
register_button = Button.new()
|
||||
register_button.text = "Create Account"
|
||||
register_button.custom_minimum_size = Vector2(0, 45)
|
||||
_style_button(register_button, true)
|
||||
register_button.pressed.connect(_on_register_pressed)
|
||||
main_vbox.add_child(register_button)
|
||||
|
||||
|
||||
func _create_message_labels() -> void:
|
||||
# Error label
|
||||
error_label = Label.new()
|
||||
error_label.text = ""
|
||||
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
error_label.add_theme_font_size_override("font_size", 13)
|
||||
error_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
error_label.visible = false
|
||||
main_vbox.add_child(error_label)
|
||||
|
||||
# Success label
|
||||
success_label = Label.new()
|
||||
success_label.text = ""
|
||||
success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
success_label.add_theme_font_size_override("font_size", 13)
|
||||
success_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||
success_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
success_label.visible = false
|
||||
main_vbox.add_child(success_label)
|
||||
|
||||
|
||||
func _create_links() -> void:
|
||||
# Login link
|
||||
login_link = Button.new()
|
||||
login_link.text = "Already have an account? Login"
|
||||
login_link.flat = true
|
||||
login_link.add_theme_font_size_override("font_size", 14)
|
||||
login_link.add_theme_color_override("font_color", Color(0.6, 0.7, 1.0))
|
||||
login_link.add_theme_color_override("font_hover_color", Color(0.8, 0.85, 1.0))
|
||||
login_link.pressed.connect(_on_login_pressed)
|
||||
main_vbox.add_child(login_link)
|
||||
|
||||
|
||||
func _create_back_button() -> void:
|
||||
var button_container = HBoxContainer.new()
|
||||
button_container.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
main_vbox.add_child(button_container)
|
||||
|
||||
back_button = Button.new()
|
||||
back_button.text = "Back"
|
||||
back_button.custom_minimum_size = Vector2(100, 38)
|
||||
_style_button(back_button, false)
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
button_container.add_child(back_button)
|
||||
|
||||
|
||||
# ======= STYLING =======
|
||||
|
||||
func _create_panel_style() -> StyleBoxFlat:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.08, 0.08, 0.12, 1.0)
|
||||
style.set_border_width_all(0)
|
||||
style.set_corner_radius_all(0)
|
||||
return style
|
||||
|
||||
|
||||
func _create_separator_style() -> StyleBoxFlat:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.5, 0.4, 0.2, 0.5)
|
||||
style.content_margin_top = 1
|
||||
return style
|
||||
|
||||
|
||||
func _style_input(input: LineEdit) -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.12, 0.12, 0.16)
|
||||
style.border_color = Color(0.4, 0.35, 0.25)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(4)
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_top = 6
|
||||
style.content_margin_bottom = 6
|
||||
|
||||
input.add_theme_stylebox_override("normal", style)
|
||||
|
||||
var focus_style = style.duplicate()
|
||||
focus_style.border_color = Color(0.7, 0.6, 0.3)
|
||||
input.add_theme_stylebox_override("focus", focus_style)
|
||||
|
||||
input.add_theme_color_override("font_color", Color(0.95, 0.9, 0.8))
|
||||
input.add_theme_color_override("font_placeholder_color", Color(0.5, 0.5, 0.55))
|
||||
input.add_theme_font_size_override("font_size", 15)
|
||||
|
||||
|
||||
func _style_button(button: Button, is_primary: bool) -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
if is_primary:
|
||||
style.bg_color = Color(0.3, 0.25, 0.15)
|
||||
style.border_color = Color(0.6, 0.5, 0.3)
|
||||
else:
|
||||
style.bg_color = Color(0.15, 0.15, 0.2)
|
||||
style.border_color = Color(0.4, 0.35, 0.25)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(6)
|
||||
style.content_margin_left = 20
|
||||
style.content_margin_right = 20
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_bottom = 8
|
||||
|
||||
button.add_theme_stylebox_override("normal", style)
|
||||
|
||||
var hover_style = style.duplicate()
|
||||
if is_primary:
|
||||
hover_style.bg_color = Color(0.4, 0.35, 0.2)
|
||||
hover_style.border_color = Color(0.8, 0.7, 0.4)
|
||||
else:
|
||||
hover_style.bg_color = Color(0.2, 0.2, 0.25)
|
||||
hover_style.border_color = Color(0.5, 0.45, 0.35)
|
||||
button.add_theme_stylebox_override("hover", hover_style)
|
||||
|
||||
var pressed_style = style.duplicate()
|
||||
pressed_style.bg_color = Color(0.1, 0.1, 0.12)
|
||||
button.add_theme_stylebox_override("pressed", pressed_style)
|
||||
|
||||
var disabled_style = style.duplicate()
|
||||
disabled_style.bg_color = Color(0.1, 0.1, 0.12)
|
||||
disabled_style.border_color = Color(0.25, 0.25, 0.3)
|
||||
button.add_theme_stylebox_override("disabled", disabled_style)
|
||||
|
||||
button.add_theme_color_override("font_color", Color(0.9, 0.85, 0.7))
|
||||
button.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.8))
|
||||
button.add_theme_color_override("font_pressed_color", Color(0.7, 0.65, 0.55))
|
||||
button.add_theme_color_override("font_disabled_color", Color(0.45, 0.42, 0.38))
|
||||
button.add_theme_font_size_override("font_size", 16)
|
||||
|
||||
|
||||
# ======= EVENT HANDLERS =======
|
||||
|
||||
func _on_input_submitted(_text: String) -> void:
|
||||
_on_register_pressed()
|
||||
|
||||
|
||||
func _on_register_pressed() -> void:
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
var email = email_input.text.strip_edges()
|
||||
var username = username_input.text.strip_edges()
|
||||
var password = password_input.text
|
||||
var confirm_password = confirm_password_input.text
|
||||
|
||||
# Validate inputs
|
||||
var validation_error = _validate_inputs(email, username, password, confirm_password)
|
||||
if validation_error != "":
|
||||
_show_error(validation_error)
|
||||
return
|
||||
|
||||
# Start registration
|
||||
_set_loading(true)
|
||||
_hide_messages()
|
||||
|
||||
var result = await NetworkManager.register(email, password, username)
|
||||
|
||||
_set_loading(false)
|
||||
|
||||
if result.success:
|
||||
_show_success(result.message)
|
||||
registration_successful.emit(result.message)
|
||||
else:
|
||||
_show_error(result.message)
|
||||
|
||||
|
||||
func _on_login_pressed() -> void:
|
||||
login_requested.emit()
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
# ======= VALIDATION =======
|
||||
|
||||
func _validate_inputs(email: String, username: String, password: String, confirm_password: String) -> String:
|
||||
# Email validation
|
||||
if email.is_empty():
|
||||
return "Please enter your email"
|
||||
|
||||
if not _is_valid_email(email):
|
||||
return "Please enter a valid email address"
|
||||
|
||||
# Username validation
|
||||
if username.is_empty():
|
||||
return "Please enter a username"
|
||||
|
||||
if username.length() < USERNAME_MIN_LENGTH:
|
||||
return "Username must be at least %d characters" % USERNAME_MIN_LENGTH
|
||||
|
||||
if username.length() > USERNAME_MAX_LENGTH:
|
||||
return "Username must be at most %d characters" % USERNAME_MAX_LENGTH
|
||||
|
||||
if not _is_valid_username(username):
|
||||
return "Username can only contain letters, numbers, underscores, and hyphens"
|
||||
|
||||
# Password validation
|
||||
if password.is_empty():
|
||||
return "Please enter a password"
|
||||
|
||||
if password.length() < PASSWORD_MIN_LENGTH:
|
||||
return "Password must be at least %d characters" % PASSWORD_MIN_LENGTH
|
||||
|
||||
# Confirm password
|
||||
if confirm_password != password:
|
||||
return "Passwords do not match"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
func _is_valid_email(email: String) -> bool:
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
|
||||
return regex.search(email) != null
|
||||
|
||||
|
||||
func _is_valid_username(username: String) -> bool:
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[a-zA-Z0-9_-]+$")
|
||||
return regex.search(username) != null
|
||||
|
||||
|
||||
# ======= HELPERS =======
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
success_label.visible = false
|
||||
|
||||
|
||||
func _show_success(message: String) -> void:
|
||||
success_label.text = message
|
||||
success_label.visible = true
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
func _hide_messages() -> void:
|
||||
error_label.visible = false
|
||||
success_label.visible = false
|
||||
|
||||
|
||||
func _set_loading(loading: bool) -> void:
|
||||
_is_loading = loading
|
||||
register_button.disabled = loading
|
||||
register_button.text = "Creating Account..." if loading else "Create Account"
|
||||
email_input.editable = not loading
|
||||
username_input.editable = not loading
|
||||
password_input.editable = not loading
|
||||
confirm_password_input.editable = not loading
|
||||
|
||||
|
||||
func clear_form() -> void:
|
||||
email_input.text = ""
|
||||
username_input.text = ""
|
||||
password_input.text = ""
|
||||
confirm_password_input.text = ""
|
||||
_hide_messages()
|
||||
_set_loading(false)
|
||||
|
||||
|
||||
func focus_email() -> void:
|
||||
email_input.grab_focus()
|
||||
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:fftcg_secret@localhost:5432/fftcg
|
||||
POSTGRES_PASSWORD=fftcg_secret
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change-this-to-a-secure-random-string
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Server ports
|
||||
HTTP_PORT=3000
|
||||
WS_PORT=3001
|
||||
|
||||
# SMTP (Email)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@fftcg.local
|
||||
|
||||
# App URL (for email links)
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Game settings
|
||||
TURN_TIMEOUT_MS=120000
|
||||
HEARTBEAT_INTERVAL_MS=10000
|
||||
HEARTBEAT_TIMEOUT_MS=30000
|
||||
|
||||
# ELO settings
|
||||
ELO_K_FACTOR=32
|
||||
ELO_STARTING_RATING=1000
|
||||
30
server/.gitignore
vendored
Normal file
30
server/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
57
server/Dockerfile
Normal file
57
server/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate --schema=src/db/schema.prisma
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy package files and install production dependencies only
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy Prisma schema for migrations
|
||||
COPY src/db/schema.prisma ./prisma/schema.prisma
|
||||
|
||||
# Generate Prisma client in production image
|
||||
RUN npx prisma generate --schema=prisma/schema.prisma
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create startup script
|
||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||
echo 'npx prisma db push --schema=prisma/schema.prisma --accept-data-loss' >> /app/start.sh && \
|
||||
echo 'node dist/index.js' >> /app/start.sh && \
|
||||
chmod +x /app/start.sh
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3000 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["/app/start.sh"]
|
||||
64
server/docker-compose.yml
Normal file
64
server/docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
game-server:
|
||||
build: .
|
||||
container_name: fftcg-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000" # REST API
|
||||
- "3001:3001" # WebSocket
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-fftcg_secret}@db:5432/fftcg
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
||||
- SMTP_HOST=${SMTP_HOST:-smtp.gmail.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-noreply@fftcg.local}
|
||||
- APP_URL=${APP_URL:-http://localhost:3000}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: fftcg-db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-fftcg_secret}
|
||||
- POSTGRES_DB=fftcg
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# Uncomment to expose DB port for external access (debugging)
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
# Optional: Add pgAdmin for database management
|
||||
# Uncomment the following to enable
|
||||
#
|
||||
# pgadmin:
|
||||
# image: dpage/pgadmin4
|
||||
# container_name: fftcg-pgadmin
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "5050:80"
|
||||
# environment:
|
||||
# - PGADMIN_DEFAULT_EMAIL=admin@fftcg.local
|
||||
# - PGADMIN_DEFAULT_PASSWORD=admin
|
||||
# depends_on:
|
||||
# - db
|
||||
23
server/jest.config.js
Normal file
23
server/jest.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
useESM: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
4757
server/package-lock.json
generated
Normal file
4757
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
server/package.json
Normal file
43
server/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "fftcg-server",
|
||||
"version": "1.0.0",
|
||||
"description": "FF-TCG Digital game server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.9",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.10.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
373
server/src/api/routes.ts
Normal file
373
server/src/api/routes.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
register,
|
||||
login,
|
||||
verifyEmail,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
resendVerification,
|
||||
} from '../auth/AuthService.js';
|
||||
import { requireAuth } from '../auth/JwtMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ============ AUTH ROUTES ============
|
||||
|
||||
// Register new account
|
||||
router.post('/auth/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password, username } = req.body;
|
||||
|
||||
if (!email || !password || !username) {
|
||||
res.status(400).json({ success: false, message: 'Email, password, and username are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await register(email, password, username);
|
||||
res.status(result.success ? 201 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/auth/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ success: false, message: 'Email and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(email, password);
|
||||
res.status(result.success ? 200 : 401).json(result);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify email
|
||||
router.post('/auth/verify-email', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await verifyEmail(token);
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Verify email error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot password
|
||||
router.post('/auth/forgot-password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
res.status(400).json({ success: false, message: 'Email is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await forgotPassword(email);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset password
|
||||
router.post('/auth/reset-password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
res.status(400).json({ success: false, message: 'Token and new password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resetPassword(token, newPassword);
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Resend verification email
|
||||
router.post('/auth/resend-verification', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
res.status(400).json({ success: false, message: 'Email is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resendVerification(email);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Resend verification error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ USER ROUTES ============
|
||||
|
||||
// Get user profile
|
||||
router.get('/user/profile', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
include: {
|
||||
stats: true,
|
||||
decks: {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
createdAt: user.createdAt,
|
||||
stats: {
|
||||
wins: user.stats?.wins ?? 0,
|
||||
losses: user.stats?.losses ?? 0,
|
||||
eloRating: user.stats?.eloRating ?? 1000,
|
||||
gamesPlayed: user.stats?.gamesPlayed ?? 0,
|
||||
},
|
||||
decks: user.decks.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
cardIds: d.cardIds,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get match history
|
||||
router.get('/user/match-history', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
OR: [{ player1Id: req.user!.userId }, { player2Id: req.user!.userId }],
|
||||
},
|
||||
include: {
|
||||
player1: { select: { username: true } },
|
||||
player2: { select: { username: true } },
|
||||
winner: { select: { username: true } },
|
||||
},
|
||||
orderBy: { playedAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
matches: matches.map((m) => ({
|
||||
id: m.id,
|
||||
player1: m.player1.username,
|
||||
player2: m.player2.username,
|
||||
winner: m.winner?.username ?? null,
|
||||
result: m.result,
|
||||
turns: m.turns,
|
||||
eloChange: m.eloChange,
|
||||
playedAt: m.playedAt,
|
||||
isWin: m.winnerId === req.user!.userId,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get match history error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ DECK ROUTES ============
|
||||
|
||||
// Save deck
|
||||
router.post('/user/decks', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, cardIds } = req.body;
|
||||
|
||||
if (!name || !cardIds || !Array.isArray(cardIds)) {
|
||||
res.status(400).json({ success: false, message: 'Name and cardIds array are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardIds.length !== 50) {
|
||||
res.status(400).json({ success: false, message: 'Deck must contain exactly 50 cards' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check deck limit (max 20 decks per user)
|
||||
const deckCount = await prisma.deck.count({
|
||||
where: { userId: req.user!.userId },
|
||||
});
|
||||
|
||||
if (deckCount >= 20) {
|
||||
res.status(400).json({ success: false, message: 'Maximum deck limit reached (20)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.create({
|
||||
data: {
|
||||
userId: req.user!.userId,
|
||||
name,
|
||||
cardIds,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
deck: {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
cardIds: deck.cardIds,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Save deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update deck
|
||||
router.put('/user/decks/:id', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, cardIds } = req.body;
|
||||
|
||||
// Verify ownership
|
||||
const existingDeck = await prisma.deck.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingDeck || existingDeck.userId !== req.user!.userId) {
|
||||
res.status(404).json({ success: false, message: 'Deck not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardIds && cardIds.length !== 50) {
|
||||
res.status(400).json({ success: false, message: 'Deck must contain exactly 50 cards' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(cardIds && { cardIds }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deck: {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
cardIds: deck.cardIds,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete deck
|
||||
router.delete('/user/decks/:id', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Verify ownership
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!deck || deck.userId !== req.user!.userId) {
|
||||
res.status(404).json({ success: false, message: 'Deck not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.deck.delete({ where: { id } });
|
||||
|
||||
res.json({ success: true, message: 'Deck deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete deck error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ LEADERBOARD ROUTES ============
|
||||
|
||||
// Get leaderboard
|
||||
router.get('/leaderboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const stats = await prisma.playerStats.findMany({
|
||||
where: {
|
||||
gamesPlayed: { gte: 10 }, // Minimum 10 games to appear on leaderboard
|
||||
},
|
||||
include: {
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
orderBy: { eloRating: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
players: stats.map((s, index) => ({
|
||||
rank: offset + index + 1,
|
||||
username: s.user.username,
|
||||
eloRating: s.eloRating,
|
||||
wins: s.wins,
|
||||
losses: s.losses,
|
||||
gamesPlayed: s.gamesPlayed,
|
||||
winRate: s.gamesPlayed > 0 ? Math.round((s.wins / s.gamesPlayed) * 100) : 0,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get leaderboard error:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
342
server/src/auth/AuthService.ts
Normal file
342
server/src/auth/AuthService.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { config } from '../config.js';
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from './EmailService.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Password requirements
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,32}$/;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
token?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
stats: {
|
||||
wins: number;
|
||||
losses: number;
|
||||
eloRating: number;
|
||||
gamesPlayed: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
function isValidUsername(username: string): boolean {
|
||||
return USERNAME_REGEX.test(username);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
function isValidPassword(password: string): { valid: boolean; message?: string } {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return { valid: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
function generateToken(user: { id: string; username: string; email: string }): string {
|
||||
const payload: JwtPayload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
};
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
export function verifyToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register a new user
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
username: string
|
||||
): Promise<AuthResult> {
|
||||
// Validate inputs
|
||||
if (!isValidEmail(email)) {
|
||||
return { success: false, message: 'Invalid email format' };
|
||||
}
|
||||
|
||||
if (!isValidUsername(username)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Username must be 3-32 characters and contain only letters, numbers, underscores, or hyphens',
|
||||
};
|
||||
}
|
||||
|
||||
const passwordValidation = isValidPassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return { success: false, message: passwordValidation.message! };
|
||||
}
|
||||
|
||||
// Check if email or username already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: email.toLowerCase() }, { username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.email === email.toLowerCase()) {
|
||||
return { success: false, message: 'Email already registered' };
|
||||
}
|
||||
return { success: false, message: 'Username already taken' };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user and stats in transaction
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.playerStats.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
eloRating: config.elo.startingRating,
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
});
|
||||
|
||||
// Create verification token
|
||||
const verificationToken = uuidv4();
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: verificationToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
},
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(email, username, verificationToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send verification email:', error);
|
||||
// Don't fail registration if email fails
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account created. Please check your email to verify your account.',
|
||||
};
|
||||
}
|
||||
|
||||
// Login user
|
||||
export async function login(email: string, password: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: { stats: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordMatch) {
|
||||
return { success: false, message: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
return { success: false, message: 'Please verify your email before logging in' };
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
stats: {
|
||||
wins: user.stats?.wins ?? 0,
|
||||
losses: user.stats?.losses ?? 0,
|
||||
eloRating: user.stats?.eloRating ?? config.elo.startingRating,
|
||||
gamesPlayed: user.stats?.gamesPlayed ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify email
|
||||
export async function verifyEmail(token: string): Promise<AuthResult> {
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: { token },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return { success: false, message: 'Invalid verification token' };
|
||||
}
|
||||
|
||||
if (verificationToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await prisma.verificationToken.delete({ where: { token } });
|
||||
return { success: false, message: 'Verification token has expired' };
|
||||
}
|
||||
|
||||
// Update user and delete token
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: verificationToken.userId },
|
||||
data: { emailVerified: true },
|
||||
}),
|
||||
prisma.verificationToken.delete({ where: { token } }),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Email verified successfully' };
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
export async function forgotPassword(email: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return { success: true, message: 'If the email exists, a reset link has been sent' };
|
||||
}
|
||||
|
||||
// Delete any existing reset tokens for this user
|
||||
await prisma.passwordResetToken.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// Create new reset token
|
||||
const resetToken = uuidv4();
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token: resetToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
},
|
||||
});
|
||||
|
||||
// Send reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, user.username, resetToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
|
||||
return { success: true, message: 'If the email exists, a reset link has been sent' };
|
||||
}
|
||||
|
||||
// Reset password
|
||||
export async function resetPassword(token: string, newPassword: string): Promise<AuthResult> {
|
||||
const passwordValidation = isValidPassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
return { success: false, message: passwordValidation.message! };
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!resetToken) {
|
||||
return { success: false, message: 'Invalid reset token' };
|
||||
}
|
||||
|
||||
if (resetToken.expiresAt < new Date()) {
|
||||
await prisma.passwordResetToken.delete({ where: { token } });
|
||||
return { success: false, message: 'Reset token has expired' };
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash },
|
||||
}),
|
||||
prisma.passwordResetToken.delete({ where: { token } }),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
// Resend verification email
|
||||
export async function resendVerification(email: string): Promise<AuthResult> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: true, message: 'If the email exists, a verification link has been sent' };
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return { success: false, message: 'Email is already verified' };
|
||||
}
|
||||
|
||||
// Delete existing verification tokens
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// Create new token
|
||||
const verificationToken = uuidv4();
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: verificationToken,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, user.username, verificationToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to send verification email:', error);
|
||||
}
|
||||
|
||||
return { success: true, message: 'Verification email sent' };
|
||||
}
|
||||
78
server/src/auth/EmailService.ts
Normal file
78
server/src/auth/EmailService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config.js';
|
||||
|
||||
// Email transporter instance
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
if (config.smtp.user && config.smtp.pass) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: {
|
||||
user: config.smtp.user,
|
||||
pass: config.smtp.pass,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// In dev mode without SMTP config, use a test account or log emails
|
||||
console.warn('SMTP not configured - emails will be logged to console');
|
||||
transporter = {
|
||||
sendMail: async (options: nodemailer.SendMailOptions) => {
|
||||
console.log('--- EMAIL (dev mode) ---');
|
||||
console.log('To:', options.to);
|
||||
console.log('Subject:', options.subject);
|
||||
console.log('Body:', options.html || options.text);
|
||||
console.log('------------------------');
|
||||
return { messageId: 'dev-mode' };
|
||||
},
|
||||
} as nodemailer.Transporter;
|
||||
}
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const verifyUrl = `${config.appUrl}/verify-email?token=${token}`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: config.smtp.from,
|
||||
to: email,
|
||||
subject: 'Verify your FF-TCG account',
|
||||
html: `
|
||||
<h1>Welcome to FF-TCG Digital, ${username}!</h1>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="${verifyUrl}">${verifyUrl}</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const resetUrl = `${config.appUrl}/reset-password?token=${token}`;
|
||||
|
||||
await getTransporter().sendMail({
|
||||
from: config.smtp.from,
|
||||
to: email,
|
||||
subject: 'Reset your FF-TCG password',
|
||||
html: `
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi ${username},</p>
|
||||
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
47
server/src/auth/JwtMiddleware.ts
Normal file
47
server/src/auth/JwtMiddleware.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyToken, JwtPayload } from './AuthService.js';
|
||||
|
||||
// Extend Express Request to include user
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ success: false, message: 'Authorization required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
res.status(401).json({ success: false, message: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware for optional authentication (doesn't fail if no token)
|
||||
export function optionalAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
if (payload) {
|
||||
req.user = payload;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
43
server/src/config.ts
Normal file
43
server/src/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Server configuration from environment variables
|
||||
|
||||
export const config = {
|
||||
// Server ports
|
||||
httpPort: parseInt(process.env.HTTP_PORT || '3000'),
|
||||
wsPort: parseInt(process.env.WS_PORT || '3001'),
|
||||
|
||||
// Database
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/fftcg',
|
||||
|
||||
// JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
|
||||
// Email (SMTP)
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
from: process.env.SMTP_FROM || 'noreply@fftcg.local',
|
||||
},
|
||||
|
||||
// App URLs
|
||||
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
||||
|
||||
// Game settings
|
||||
turnTimeoutMs: parseInt(process.env.TURN_TIMEOUT_MS || '120000'), // 2 minutes
|
||||
heartbeatIntervalMs: parseInt(process.env.HEARTBEAT_INTERVAL_MS || '10000'), // 10 seconds
|
||||
heartbeatTimeoutMs: parseInt(process.env.HEARTBEAT_TIMEOUT_MS || '30000'), // 30 seconds
|
||||
reconnectWindowMs: parseInt(process.env.RECONNECT_WINDOW_MS || '300000'), // 5 minutes
|
||||
roomCodeExpiryMs: parseInt(process.env.ROOM_CODE_EXPIRY_MS || '3600000'), // 1 hour
|
||||
|
||||
// ELO settings
|
||||
elo: {
|
||||
kFactor: parseInt(process.env.ELO_K_FACTOR || '32'),
|
||||
startingRating: parseInt(process.env.ELO_STARTING_RATING || '1000'),
|
||||
},
|
||||
|
||||
// Environment
|
||||
isDev: process.env.NODE_ENV !== 'production',
|
||||
};
|
||||
144
server/src/db/GameDatabase.ts
Normal file
144
server/src/db/GameDatabase.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// GameDatabase - Database operations for game sessions and player stats
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { GameSessionData } from '../game/GameSession.js';
|
||||
import { EloResult } from '../game/EloCalculator.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Default ELO rating for new players or players without stats
|
||||
const DEFAULT_ELO = 1000;
|
||||
|
||||
/**
|
||||
* Get a player's ELO rating from the database
|
||||
* Returns the player's ELO or default value if not found
|
||||
*/
|
||||
export async function getPlayerElo(userId: string): Promise<number> {
|
||||
try {
|
||||
const stats = await prisma.playerStats.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
return stats?.eloRating ?? DEFAULT_ELO;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ELO for user ${userId}:`, error);
|
||||
return DEFAULT_ELO;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a completed match and update player stats
|
||||
*/
|
||||
export async function recordMatchResult(
|
||||
session: GameSessionData,
|
||||
eloResult: EloResult
|
||||
): Promise<void> {
|
||||
const player1 = session.players[0];
|
||||
const player2 = session.players[1];
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all updates happen together
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create the match record
|
||||
await tx.match.create({
|
||||
data: {
|
||||
player1Id: player1.userId,
|
||||
player2Id: player2.userId,
|
||||
winnerId: session.winnerId,
|
||||
player1Deck: player1.deckId,
|
||||
player2Deck: player2.deckId,
|
||||
result: session.endReason,
|
||||
turns: session.turnNumber,
|
||||
eloChange: Math.abs(eloResult.player1Change), // Store absolute change
|
||||
},
|
||||
});
|
||||
|
||||
// Update player 1 stats (upsert in case stats don't exist yet)
|
||||
const player1Won = session.winnerId === player1.userId;
|
||||
await tx.playerStats.upsert({
|
||||
where: { userId: player1.userId },
|
||||
create: {
|
||||
userId: player1.userId,
|
||||
wins: player1Won ? 1 : 0,
|
||||
losses: player1Won ? 0 : 1,
|
||||
eloRating: eloResult.player1NewElo,
|
||||
gamesPlayed: 1,
|
||||
},
|
||||
update: {
|
||||
wins: { increment: player1Won ? 1 : 0 },
|
||||
losses: { increment: player1Won ? 0 : 1 },
|
||||
eloRating: eloResult.player1NewElo,
|
||||
gamesPlayed: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Update player 2 stats (upsert in case stats don't exist yet)
|
||||
const player2Won = session.winnerId === player2.userId;
|
||||
await tx.playerStats.upsert({
|
||||
where: { userId: player2.userId },
|
||||
create: {
|
||||
userId: player2.userId,
|
||||
wins: player2Won ? 1 : 0,
|
||||
losses: player2Won ? 0 : 1,
|
||||
eloRating: eloResult.player2NewElo,
|
||||
gamesPlayed: 1,
|
||||
},
|
||||
update: {
|
||||
wins: { increment: player2Won ? 1 : 0 },
|
||||
losses: { increment: player2Won ? 0 : 1 },
|
||||
eloRating: eloResult.player2NewElo,
|
||||
gamesPlayed: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Update last login timestamps for both players
|
||||
const now = new Date();
|
||||
await tx.user.update({
|
||||
where: { id: player1.userId },
|
||||
data: { lastLogin: now },
|
||||
});
|
||||
await tx.user.update({
|
||||
where: { id: player2.userId },
|
||||
data: { lastLogin: now },
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Match recorded: ${player1.username} vs ${player2.username}, ` +
|
||||
`winner=${session.winnerId === player1.userId ? player1.username : player2.username}, ` +
|
||||
`reason=${session.endReason}`
|
||||
);
|
||||
console.log(
|
||||
`ELO updated: ${player1.username}: ${eloResult.player1NewElo} (${eloResult.player1Change > 0 ? '+' : ''}${eloResult.player1Change}), ` +
|
||||
`${player2.username}: ${eloResult.player2NewElo} (${eloResult.player2Change > 0 ? '+' : ''}${eloResult.player2Change})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error recording match result:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple player ELO ratings in a single query
|
||||
*/
|
||||
export async function getPlayersElo(userIds: string[]): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
|
||||
try {
|
||||
const stats = await prisma.playerStats.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { userId: true, eloRating: true },
|
||||
});
|
||||
|
||||
// Initialize all with default ELO
|
||||
userIds.forEach(id => result.set(id, DEFAULT_ELO));
|
||||
|
||||
// Override with actual values from database
|
||||
stats.forEach(s => result.set(s.userId, s.eloRating));
|
||||
} catch (error) {
|
||||
console.error('Error fetching player ELOs:', error);
|
||||
// Return defaults on error
|
||||
userIds.forEach(id => result.set(id, DEFAULT_ELO));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
92
server/src/db/schema.prisma
Normal file
92
server/src/db/schema.prisma
Normal file
@@ -0,0 +1,92 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
passwordHash String @map("password_hash")
|
||||
username String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
lastLogin DateTime? @map("last_login")
|
||||
|
||||
stats PlayerStats?
|
||||
decks Deck[]
|
||||
matchesAsPlayer1 Match[] @relation("Player1Matches")
|
||||
matchesAsPlayer2 Match[] @relation("Player2Matches")
|
||||
matchesWon Match[] @relation("WinnerMatches")
|
||||
verificationTokens VerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model PlayerStats {
|
||||
userId String @id @map("user_id")
|
||||
wins Int @default(0)
|
||||
losses Int @default(0)
|
||||
eloRating Int @default(1000) @map("elo_rating")
|
||||
gamesPlayed Int @default(0) @map("games_played")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("player_stats")
|
||||
}
|
||||
|
||||
model Deck {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
cardIds Json @map("card_ids")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("decks")
|
||||
}
|
||||
|
||||
model Match {
|
||||
id String @id @default(uuid())
|
||||
player1Id String @map("player1_id")
|
||||
player2Id String @map("player2_id")
|
||||
winnerId String? @map("winner_id")
|
||||
player1Deck Json? @map("player1_deck")
|
||||
player2Deck Json? @map("player2_deck")
|
||||
result String? // 'damage', 'deck_out', 'concede', 'timeout'
|
||||
turns Int?
|
||||
eloChange Int? @map("elo_change")
|
||||
playedAt DateTime @default(now()) @map("played_at")
|
||||
|
||||
player1 User @relation("Player1Matches", fields: [player1Id], references: [id])
|
||||
player2 User @relation("Player2Matches", fields: [player2Id], references: [id])
|
||||
winner User? @relation("WinnerMatches", fields: [winnerId], references: [id])
|
||||
|
||||
@@map("matches")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @id
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
token String @id
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
66
server/src/game/EloCalculator.ts
Normal file
66
server/src/game/EloCalculator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// EloCalculator - Standard ELO rating system for competitive play
|
||||
|
||||
export const K_FACTOR = 32; // Standard K-factor for new players
|
||||
export const DEFAULT_ELO = 1000; // Starting rating
|
||||
|
||||
export interface EloResult {
|
||||
player1NewElo: number;
|
||||
player2NewElo: number;
|
||||
player1Change: number;
|
||||
player2Change: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expected score for a player against an opponent
|
||||
* @param playerElo Player's current ELO rating
|
||||
* @param opponentElo Opponent's current ELO rating
|
||||
* @returns Expected score (0 to 1, where 1 means expected win)
|
||||
*/
|
||||
export function calculateExpectedScore(playerElo: number, opponentElo: number): number {
|
||||
return 1 / (1 + Math.pow(10, (opponentElo - playerElo) / 400));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ELO rating changes after a match
|
||||
* @param player1Elo Player 1's current ELO rating
|
||||
* @param player2Elo Player 2's current ELO rating
|
||||
* @param player1Won Whether player 1 won the match
|
||||
* @returns New ELO ratings and changes for both players
|
||||
*/
|
||||
export function calculateEloChange(
|
||||
player1Elo: number,
|
||||
player2Elo: number,
|
||||
player1Won: boolean
|
||||
): EloResult {
|
||||
const expected1 = calculateExpectedScore(player1Elo, player2Elo);
|
||||
const expected2 = calculateExpectedScore(player2Elo, player1Elo);
|
||||
|
||||
// Actual score: 1 for win, 0 for loss
|
||||
const actual1 = player1Won ? 1 : 0;
|
||||
const actual2 = player1Won ? 0 : 1;
|
||||
|
||||
// ELO change formula: K * (actual - expected)
|
||||
const change1 = Math.round(K_FACTOR * (actual1 - expected1));
|
||||
const change2 = Math.round(K_FACTOR * (actual2 - expected2));
|
||||
|
||||
return {
|
||||
player1NewElo: Math.max(0, player1Elo + change1), // Prevent negative ELO
|
||||
player2NewElo: Math.max(0, player2Elo + change2),
|
||||
player1Change: change1,
|
||||
player2Change: change2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ELO change for a specific outcome
|
||||
* Useful for preview calculations
|
||||
*/
|
||||
export function calculatePotentialChange(
|
||||
playerElo: number,
|
||||
opponentElo: number,
|
||||
win: boolean
|
||||
): number {
|
||||
const expected = calculateExpectedScore(playerElo, opponentElo);
|
||||
const actual = win ? 1 : 0;
|
||||
return Math.round(K_FACTOR * (actual - expected));
|
||||
}
|
||||
498
server/src/game/GameSession.ts
Normal file
498
server/src/game/GameSession.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
// GameSession - Represents an active game between two players
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import { TurnTimer } from './TurnTimer.js';
|
||||
import { calculateEloChange, EloResult } from './EloCalculator.js';
|
||||
import { AuthenticatedSocket } from '../matchmaking/MatchmakingService.js';
|
||||
|
||||
// Game phases matching Godot's Enums.gd
|
||||
export enum TurnPhase {
|
||||
ACTIVE = 0,
|
||||
DRAW = 1,
|
||||
MAIN_1 = 2,
|
||||
ATTACK = 3,
|
||||
MAIN_2 = 4,
|
||||
END = 5,
|
||||
}
|
||||
|
||||
// Attack sub-steps within ATTACK phase
|
||||
export enum AttackStep {
|
||||
NONE = 0,
|
||||
PREPARATION = 1,
|
||||
DECLARATION = 2,
|
||||
BLOCK_DECLARATION = 3,
|
||||
DAMAGE_RESOLUTION = 4,
|
||||
}
|
||||
|
||||
export interface SessionPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface GameSessionData {
|
||||
gameId: string;
|
||||
players: [SessionPlayer, SessionPlayer];
|
||||
currentPlayerIndex: number;
|
||||
currentPhase: TurnPhase;
|
||||
attackStep: AttackStep;
|
||||
turnNumber: number;
|
||||
createdAt: number;
|
||||
startedAt: number | null;
|
||||
endedAt: number | null;
|
||||
winnerId: string | null;
|
||||
endReason: string | null;
|
||||
}
|
||||
|
||||
type GameEndCallback = (session: GameSessionData, eloResult: EloResult) => void;
|
||||
|
||||
const TURN_TIMEOUT_MS = 120000; // 2 minutes
|
||||
const DISCONNECT_TIMEOUT_MS = 60000; // 60 seconds to reconnect
|
||||
|
||||
export class GameSessionInstance {
|
||||
private session: GameSessionData;
|
||||
private turnTimer: TurnTimer;
|
||||
private onGameEnd: GameEndCallback;
|
||||
private disconnectTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
constructor(
|
||||
gameId: string,
|
||||
player1: Omit<SessionPlayer, 'connected'>,
|
||||
player2: Omit<SessionPlayer, 'connected'>,
|
||||
firstPlayer: number,
|
||||
onGameEnd: GameEndCallback
|
||||
) {
|
||||
this.onGameEnd = onGameEnd;
|
||||
|
||||
this.session = {
|
||||
gameId,
|
||||
players: [
|
||||
{ ...player1, connected: true },
|
||||
{ ...player2, connected: true },
|
||||
],
|
||||
currentPlayerIndex: firstPlayer,
|
||||
currentPhase: TurnPhase.ACTIVE,
|
||||
attackStep: AttackStep.NONE,
|
||||
turnNumber: 1,
|
||||
createdAt: Date.now(),
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
winnerId: null,
|
||||
endReason: null,
|
||||
};
|
||||
|
||||
this.turnTimer = new TurnTimer(
|
||||
TURN_TIMEOUT_MS,
|
||||
() => this.handleTimeout(),
|
||||
(seconds) => this.broadcastTimer(seconds)
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.session.startedAt = Date.now();
|
||||
this.turnTimer.start();
|
||||
this.broadcastGameStart();
|
||||
}
|
||||
|
||||
getSession(): GameSessionData {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
getGameId(): string {
|
||||
return this.session.gameId;
|
||||
}
|
||||
|
||||
isEnded(): boolean {
|
||||
return this.session.endedAt !== null;
|
||||
}
|
||||
|
||||
// Validate and process incoming action
|
||||
handleAction(
|
||||
userId: string,
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { success: boolean; error?: string } {
|
||||
if (this.isEnded()) {
|
||||
return { success: false, error: 'Game has ended' };
|
||||
}
|
||||
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1) {
|
||||
return { success: false, error: 'Player not in this game' };
|
||||
}
|
||||
|
||||
// Validate turn order (except for blocks - defending player acts)
|
||||
if (actionType !== 'block' && playerIndex !== this.session.currentPlayerIndex) {
|
||||
return { success: false, error: 'Not your turn' };
|
||||
}
|
||||
|
||||
// Validate payload structure
|
||||
const payloadValidation = this.validatePayload(actionType, payload);
|
||||
if (!payloadValidation.valid) {
|
||||
return { success: false, error: payloadValidation.error };
|
||||
}
|
||||
|
||||
// Validate phase and action combination
|
||||
const validation = this.validateActionForPhase(actionType, playerIndex);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Action is valid - relay to opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_action', {
|
||||
action_type: actionType,
|
||||
payload,
|
||||
});
|
||||
|
||||
// Send confirmation to acting player
|
||||
this.sendToPlayer(playerIndex, 'action_confirmed', {
|
||||
action_type: actionType,
|
||||
});
|
||||
|
||||
// Handle phase transitions and side effects
|
||||
this.processActionSideEffects(actionType, playerIndex);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private validatePayload(
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { valid: boolean; error?: string } {
|
||||
switch (actionType) {
|
||||
case 'play_card':
|
||||
case 'discard_cp':
|
||||
case 'dull_backup_cp':
|
||||
if (typeof payload.card_instance_id !== 'number') {
|
||||
return { valid: false, error: 'Invalid card_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
if (typeof payload.attacker_instance_id !== 'number') {
|
||||
return { valid: false, error: 'Invalid attacker_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
// blocker_instance_id can be null (no block) or a number
|
||||
if (
|
||||
payload.blocker_instance_id !== null &&
|
||||
typeof payload.blocker_instance_id !== 'number'
|
||||
) {
|
||||
return { valid: false, error: 'Invalid blocker_instance_id' };
|
||||
}
|
||||
break;
|
||||
|
||||
// pass, concede, attack_resolved, report_game_end don't require specific payload validation
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
private validateActionForPhase(
|
||||
actionType: string,
|
||||
playerIndex: number
|
||||
): { valid: boolean; error?: string } {
|
||||
const phase = this.session.currentPhase;
|
||||
const attackStep = this.session.attackStep;
|
||||
const isCurrentPlayer = playerIndex === this.session.currentPlayerIndex;
|
||||
|
||||
switch (actionType) {
|
||||
case 'play_card':
|
||||
case 'discard_cp':
|
||||
case 'dull_backup_cp':
|
||||
if (phase !== TurnPhase.MAIN_1 && phase !== TurnPhase.MAIN_2) {
|
||||
return { valid: false, error: 'Can only play cards during Main phases' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
if (phase !== TurnPhase.ATTACK) {
|
||||
return { valid: false, error: 'Can only attack during Attack phase' };
|
||||
}
|
||||
if (attackStep !== AttackStep.DECLARATION && attackStep !== AttackStep.PREPARATION) {
|
||||
return { valid: false, error: 'Cannot declare attack now' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
if (phase !== TurnPhase.ATTACK || attackStep !== AttackStep.BLOCK_DECLARATION) {
|
||||
return { valid: false, error: 'Cannot declare block now' };
|
||||
}
|
||||
// Block is valid from defending player (not current player)
|
||||
if (isCurrentPlayer) {
|
||||
return { valid: false, error: 'Attacker cannot block' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pass':
|
||||
// Pass is generally always valid for current player
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'concede':
|
||||
// Concede is always valid from either player
|
||||
break;
|
||||
|
||||
case 'attack_resolved':
|
||||
if (phase !== TurnPhase.ATTACK || attackStep !== AttackStep.DAMAGE_RESOLUTION) {
|
||||
return { valid: false, error: 'Cannot resolve attack now' };
|
||||
}
|
||||
if (!isCurrentPlayer) {
|
||||
return { valid: false, error: 'Not your turn' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'report_game_end':
|
||||
// Client reports game end (damage/deck_out)
|
||||
// Always valid - server will validate the reason
|
||||
break;
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown action: ${actionType}` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
private processActionSideEffects(actionType: string, playerIndex: number): void {
|
||||
switch (actionType) {
|
||||
case 'pass':
|
||||
this.advancePhase();
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
this.session.attackStep = AttackStep.BLOCK_DECLARATION;
|
||||
this.broadcastPhaseChange(); // Notify defender to block
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
this.session.attackStep = AttackStep.DAMAGE_RESOLUTION;
|
||||
// After damage resolution, client will send 'attack_resolved'
|
||||
// which returns to DECLARATION for more attacks
|
||||
break;
|
||||
|
||||
case 'attack_resolved':
|
||||
// Combat resolved, allow more attacks or pass
|
||||
this.session.attackStep = AttackStep.DECLARATION;
|
||||
break;
|
||||
|
||||
case 'concede':
|
||||
const winnerId = this.session.players[playerIndex === 0 ? 1 : 0].userId;
|
||||
this.endGame(winnerId, 'concede');
|
||||
break;
|
||||
|
||||
case 'report_game_end':
|
||||
// Client reports winner - trust client for now
|
||||
// In future, could validate with game state tracking
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called by client when they detect game over (7 damage or deck out)
|
||||
reportGameEnd(winnerId: string, reason: string): void {
|
||||
if (!this.isEnded()) {
|
||||
this.endGame(winnerId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private advancePhase(): void {
|
||||
const phases = [
|
||||
TurnPhase.ACTIVE,
|
||||
TurnPhase.DRAW,
|
||||
TurnPhase.MAIN_1,
|
||||
TurnPhase.ATTACK,
|
||||
TurnPhase.MAIN_2,
|
||||
TurnPhase.END,
|
||||
];
|
||||
const currentIndex = phases.indexOf(this.session.currentPhase);
|
||||
|
||||
if (this.session.currentPhase === TurnPhase.END) {
|
||||
// End of turn - switch players
|
||||
this.session.currentPlayerIndex = this.session.currentPlayerIndex === 0 ? 1 : 0;
|
||||
this.session.currentPhase = TurnPhase.ACTIVE;
|
||||
this.session.turnNumber++;
|
||||
this.turnTimer.reset(); // Reset timer for new turn
|
||||
} else {
|
||||
this.session.currentPhase = phases[currentIndex + 1];
|
||||
|
||||
if (this.session.currentPhase === TurnPhase.ATTACK) {
|
||||
this.session.attackStep = AttackStep.PREPARATION;
|
||||
} else {
|
||||
this.session.attackStep = AttackStep.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcastPhaseChange();
|
||||
}
|
||||
|
||||
private handleTimeout(): void {
|
||||
// Current player loses due to timeout
|
||||
const winnerId = this.session.players[this.session.currentPlayerIndex === 0 ? 1 : 0].userId;
|
||||
this.endGame(winnerId, 'timeout');
|
||||
}
|
||||
|
||||
private endGame(winnerId: string, reason: string): void {
|
||||
if (this.isEnded()) return;
|
||||
|
||||
this.turnTimer.stop();
|
||||
this.session.endedAt = Date.now();
|
||||
this.session.winnerId = winnerId;
|
||||
this.session.endReason = reason;
|
||||
|
||||
// Clear any pending disconnect timeouts
|
||||
this.disconnectTimeouts.forEach((timeout) => clearTimeout(timeout));
|
||||
this.disconnectTimeouts.clear();
|
||||
|
||||
// Calculate ELO changes
|
||||
const p1Won = winnerId === this.session.players[0].userId;
|
||||
const eloResult = calculateEloChange(
|
||||
this.session.players[0].eloRating,
|
||||
this.session.players[1].eloRating,
|
||||
p1Won
|
||||
);
|
||||
|
||||
// Broadcast game end to both players
|
||||
this.session.players.forEach((player, index) => {
|
||||
const isWinner = player.userId === winnerId;
|
||||
const eloChange = index === 0 ? eloResult.player1Change : eloResult.player2Change;
|
||||
const newElo = index === 0 ? eloResult.player1NewElo : eloResult.player2NewElo;
|
||||
const winnerPlayer = this.session.players.find((p) => p.userId === winnerId);
|
||||
|
||||
this.sendToPlayer(index, 'game_ended', {
|
||||
winner_id: winnerId,
|
||||
winner_username: winnerPlayer?.username || 'Unknown',
|
||||
reason,
|
||||
is_winner: isWinner,
|
||||
elo_change: eloChange,
|
||||
new_elo: newElo,
|
||||
turns: this.session.turnNumber,
|
||||
});
|
||||
});
|
||||
|
||||
this.onGameEnd(this.session, eloResult);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1 || this.isEnded()) return;
|
||||
|
||||
this.session.players[playerIndex].connected = false;
|
||||
|
||||
// Notify opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_disconnected', {
|
||||
reconnect_timeout_seconds: DISCONNECT_TIMEOUT_MS / 1000,
|
||||
});
|
||||
|
||||
// Start disconnect timeout (forfeit after 60s)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.session.players[playerIndex].connected && !this.isEnded()) {
|
||||
const winnerId = this.session.players[opponentIndex].userId;
|
||||
this.endGame(winnerId, 'disconnect');
|
||||
}
|
||||
}, DISCONNECT_TIMEOUT_MS);
|
||||
|
||||
this.disconnectTimeouts.set(userId, timeout);
|
||||
}
|
||||
|
||||
handleReconnect(userId: string, socket: AuthenticatedSocket): boolean {
|
||||
const playerIndex = this.getPlayerIndex(userId);
|
||||
if (playerIndex === -1) return false;
|
||||
|
||||
// Clear disconnect timeout
|
||||
const timeout = this.disconnectTimeouts.get(userId);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.disconnectTimeouts.delete(userId);
|
||||
}
|
||||
|
||||
// Update socket and connection status
|
||||
this.session.players[playerIndex].socket = socket;
|
||||
this.session.players[playerIndex].connected = true;
|
||||
|
||||
// Send current game state to reconnected player
|
||||
this.sendToPlayer(playerIndex, 'game_state_sync', this.getStateForPlayer(playerIndex));
|
||||
|
||||
// Notify opponent
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
this.sendToPlayer(opponentIndex, 'opponent_reconnected', {});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPlayerIndex(userId: string): number {
|
||||
return this.session.players.findIndex((p) => p.userId === userId);
|
||||
}
|
||||
|
||||
private sendToPlayer(playerIndex: number, type: string, payload: Record<string, unknown>): void {
|
||||
const player = this.session.players[playerIndex];
|
||||
if (player && player.socket.readyState === WebSocket.OPEN) {
|
||||
player.socket.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(type: string, payload: Record<string, unknown>): void {
|
||||
this.sendToPlayer(0, type, payload);
|
||||
this.sendToPlayer(1, type, payload);
|
||||
}
|
||||
|
||||
private broadcastTimer(seconds: number): void {
|
||||
this.broadcast('turn_timer', { seconds_remaining: seconds });
|
||||
}
|
||||
|
||||
private broadcastPhaseChange(): void {
|
||||
this.broadcast('phase_changed', {
|
||||
phase: this.session.currentPhase,
|
||||
attack_step: this.session.attackStep,
|
||||
current_player_index: this.session.currentPlayerIndex,
|
||||
turn_number: this.session.turnNumber,
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastGameStart(): void {
|
||||
this.session.players.forEach((player, index) => {
|
||||
const opponentIndex = index === 0 ? 1 : 0;
|
||||
this.sendToPlayer(index, 'game_start', {
|
||||
game_id: this.session.gameId,
|
||||
your_player_index: index,
|
||||
opponent: {
|
||||
username: this.session.players[opponentIndex].username,
|
||||
elo: this.session.players[opponentIndex].eloRating,
|
||||
},
|
||||
first_player: this.session.currentPlayerIndex,
|
||||
turn_time_seconds: TURN_TIMEOUT_MS / 1000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getStateForPlayer(playerIndex: number): Record<string, unknown> {
|
||||
const opponentIndex = playerIndex === 0 ? 1 : 0;
|
||||
return {
|
||||
game_id: this.session.gameId,
|
||||
your_player_index: playerIndex,
|
||||
opponent: {
|
||||
username: this.session.players[opponentIndex].username,
|
||||
elo: this.session.players[opponentIndex].eloRating,
|
||||
},
|
||||
current_player_index: this.session.currentPlayerIndex,
|
||||
current_phase: this.session.currentPhase,
|
||||
attack_step: this.session.attackStep,
|
||||
turn_number: this.session.turnNumber,
|
||||
turn_timer_seconds: this.turnTimer.getRemainingSeconds(),
|
||||
};
|
||||
}
|
||||
}
|
||||
159
server/src/game/GameSessionManager.ts
Normal file
159
server/src/game/GameSessionManager.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// GameSessionManager - Manages all active game sessions
|
||||
|
||||
import { GameSessionInstance, GameSessionData, SessionPlayer } from './GameSession.js';
|
||||
import { EloResult } from './EloCalculator.js';
|
||||
import { AuthenticatedSocket } from '../matchmaking/MatchmakingService.js';
|
||||
|
||||
type GameEndHandler = (session: GameSessionData, eloResult: EloResult) => Promise<void>;
|
||||
|
||||
export class GameSessionManager {
|
||||
private sessions: Map<string, GameSessionInstance> = new Map();
|
||||
private userToGame: Map<string, string> = new Map(); // userId -> gameId
|
||||
private onGameEndHandler: GameEndHandler;
|
||||
|
||||
constructor(onGameEnd: GameEndHandler) {
|
||||
this.onGameEndHandler = onGameEnd;
|
||||
console.log('GameSessionManager initialized');
|
||||
}
|
||||
|
||||
createSession(
|
||||
gameId: string,
|
||||
player1: Omit<SessionPlayer, 'connected'>,
|
||||
player2: Omit<SessionPlayer, 'connected'>,
|
||||
firstPlayer: number
|
||||
): GameSessionInstance {
|
||||
// Clean up any existing sessions for these players
|
||||
this.cleanupPlayerSessions(player1.userId);
|
||||
this.cleanupPlayerSessions(player2.userId);
|
||||
|
||||
const session = new GameSessionInstance(
|
||||
gameId,
|
||||
player1,
|
||||
player2,
|
||||
firstPlayer,
|
||||
(endedSession, eloResult) => this.handleGameEnd(endedSession, eloResult)
|
||||
);
|
||||
|
||||
this.sessions.set(gameId, session);
|
||||
this.userToGame.set(player1.userId, gameId);
|
||||
this.userToGame.set(player2.userId, gameId);
|
||||
|
||||
session.start();
|
||||
console.log(
|
||||
`Game session ${gameId} created: ${player1.username} vs ${player2.username}, first player: ${firstPlayer}`
|
||||
);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
handleAction(
|
||||
userId: string,
|
||||
gameId: string,
|
||||
actionType: string,
|
||||
payload: Record<string, unknown>
|
||||
): { success: boolean; error?: string } {
|
||||
const session = this.sessions.get(gameId);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Game session not found' };
|
||||
}
|
||||
|
||||
// Verify user is in this game
|
||||
const userGameId = this.userToGame.get(userId);
|
||||
if (userGameId !== gameId) {
|
||||
return { success: false, error: 'Not in this game' };
|
||||
}
|
||||
|
||||
return session.handleAction(userId, actionType, payload);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
if (session && !session.isEnded()) {
|
||||
session.handleDisconnect(userId);
|
||||
}
|
||||
}
|
||||
|
||||
handleReconnect(userId: string, socket: AuthenticatedSocket): boolean {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return false;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
if (session && !session.isEnded()) {
|
||||
return session.handleReconnect(userId, socket);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSessionByGameId(gameId: string): GameSessionInstance | undefined {
|
||||
return this.sessions.get(gameId);
|
||||
}
|
||||
|
||||
getSessionByUserId(userId: string): GameSessionInstance | undefined {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
return gameId ? this.sessions.get(gameId) : undefined;
|
||||
}
|
||||
|
||||
getGameIdByUserId(userId: string): string | undefined {
|
||||
return this.userToGame.get(userId);
|
||||
}
|
||||
|
||||
isInGame(userId: string): boolean {
|
||||
const gameId = this.userToGame.get(userId);
|
||||
if (!gameId) return false;
|
||||
|
||||
const session = this.sessions.get(gameId);
|
||||
return session !== undefined && !session.isEnded();
|
||||
}
|
||||
|
||||
getActiveSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
private cleanupPlayerSessions(userId: string): void {
|
||||
const existingGameId = this.userToGame.get(userId);
|
||||
if (existingGameId) {
|
||||
const existingSession = this.sessions.get(existingGameId);
|
||||
if (existingSession && !existingSession.isEnded()) {
|
||||
console.warn(`Player ${userId} already in game ${existingGameId}, cleaning up`);
|
||||
// Force end the existing game as disconnect
|
||||
existingSession.handleDisconnect(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGameEnd(session: GameSessionData, eloResult: EloResult): Promise<void> {
|
||||
// Clean up tracking
|
||||
this.sessions.delete(session.gameId);
|
||||
session.players.forEach((p) => this.userToGame.delete(p.userId));
|
||||
|
||||
console.log(
|
||||
`Game ${session.gameId} ended: winner=${session.winnerId}, reason=${session.endReason}, turns=${session.turnNumber}`
|
||||
);
|
||||
console.log(
|
||||
`ELO changes: ${session.players[0].username}: ${eloResult.player1Change}, ${session.players[1].username}: ${eloResult.player2Change}`
|
||||
);
|
||||
|
||||
// Call external handler (for DB updates)
|
||||
try {
|
||||
await this.onGameEndHandler(session, eloResult);
|
||||
} catch (error) {
|
||||
console.error('Error in game end handler:', error);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
// End all active games
|
||||
for (const [gameId, session] of this.sessions.entries()) {
|
||||
if (!session.isEnded()) {
|
||||
console.log(`Shutting down game session ${gameId}`);
|
||||
// Games will be ended without a winner due to server shutdown
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.userToGame.clear();
|
||||
console.log('GameSessionManager shutdown');
|
||||
}
|
||||
}
|
||||
76
server/src/game/TurnTimer.ts
Normal file
76
server/src/game/TurnTimer.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// TurnTimer - Manages the 2-minute turn timer for online games
|
||||
|
||||
type TimerCallback = () => void;
|
||||
type BroadcastCallback = (secondsRemaining: number) => void;
|
||||
|
||||
export class TurnTimer {
|
||||
private timeoutMs: number;
|
||||
private onTimeout: TimerCallback;
|
||||
private onBroadcast: BroadcastCallback;
|
||||
private startTime: number = 0;
|
||||
private timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private broadcastInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
timeoutMs: number,
|
||||
onTimeout: TimerCallback,
|
||||
onBroadcast: BroadcastCallback
|
||||
) {
|
||||
this.timeoutMs = timeoutMs; // 120000 for 2 minutes
|
||||
this.onTimeout = onTimeout;
|
||||
this.onBroadcast = onBroadcast;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stop();
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Main timeout - triggers forfeit when time runs out
|
||||
this.timerId = setTimeout(() => {
|
||||
this.stop();
|
||||
this.onTimeout();
|
||||
}, this.timeoutMs);
|
||||
|
||||
// Broadcast remaining time every second
|
||||
this.broadcastInterval = setInterval(() => {
|
||||
this.onBroadcast(this.getRemainingSeconds());
|
||||
}, 1000);
|
||||
|
||||
// Initial broadcast
|
||||
this.onBroadcast(this.getRemainingSeconds());
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timerId) {
|
||||
clearTimeout(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
if (this.broadcastInterval) {
|
||||
clearInterval(this.broadcastInterval);
|
||||
this.broadcastInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
// Restart timer with full time (used at start of each turn)
|
||||
this.start();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
// Stop broadcasting but remember elapsed time
|
||||
// Useful for handling disconnects
|
||||
this.stop();
|
||||
}
|
||||
|
||||
getRemainingSeconds(): number {
|
||||
if (!this.startTime) {
|
||||
return Math.floor(this.timeoutMs / 1000);
|
||||
}
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
return Math.max(0, Math.floor((this.timeoutMs - elapsed) / 1000));
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
}
|
||||
152
server/src/game/__tests__/EloCalculator.test.ts
Normal file
152
server/src/game/__tests__/EloCalculator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
calculateExpectedScore,
|
||||
calculateEloChange,
|
||||
calculatePotentialChange,
|
||||
K_FACTOR,
|
||||
DEFAULT_ELO,
|
||||
} from '../EloCalculator';
|
||||
|
||||
describe('EloCalculator', () => {
|
||||
describe('constants', () => {
|
||||
it('should have K_FACTOR of 32', () => {
|
||||
expect(K_FACTOR).toBe(32);
|
||||
});
|
||||
|
||||
it('should have DEFAULT_ELO of 1000', () => {
|
||||
expect(DEFAULT_ELO).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateExpectedScore', () => {
|
||||
it('should return 0.5 for equal ELO ratings', () => {
|
||||
const expected = calculateExpectedScore(1000, 1000);
|
||||
expect(expected).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should return higher value for higher rated player', () => {
|
||||
const higherRated = calculateExpectedScore(1200, 1000);
|
||||
const lowerRated = calculateExpectedScore(1000, 1200);
|
||||
|
||||
expect(higherRated).toBeGreaterThan(0.5);
|
||||
expect(lowerRated).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('should return values between 0 and 1', () => {
|
||||
const result1 = calculateExpectedScore(1500, 1000);
|
||||
const result2 = calculateExpectedScore(1000, 1500);
|
||||
|
||||
expect(result1).toBeGreaterThan(0);
|
||||
expect(result1).toBeLessThan(1);
|
||||
expect(result2).toBeGreaterThan(0);
|
||||
expect(result2).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should return approximately 0.76 for 200 ELO advantage', () => {
|
||||
const expected = calculateExpectedScore(1200, 1000);
|
||||
expect(expected).toBeCloseTo(0.76, 2);
|
||||
});
|
||||
|
||||
it('should return approximately 0.24 for 200 ELO disadvantage', () => {
|
||||
const expected = calculateExpectedScore(1000, 1200);
|
||||
expect(expected).toBeCloseTo(0.24, 2);
|
||||
});
|
||||
|
||||
it('should return sum of 1 for both players', () => {
|
||||
const p1Expected = calculateExpectedScore(1000, 1200);
|
||||
const p2Expected = calculateExpectedScore(1200, 1000);
|
||||
|
||||
expect(p1Expected + p2Expected).toBeCloseTo(1, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEloChange', () => {
|
||||
it('should give positive change to winner', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should give negative change to loser', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player2Change).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should give equal and opposite changes for equal ELO', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBe(-result.player2Change);
|
||||
});
|
||||
|
||||
it('should give +16/-16 for equal ELO match', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1Change).toBe(16);
|
||||
expect(result.player2Change).toBe(-16);
|
||||
});
|
||||
|
||||
it('should calculate new ELO correctly', () => {
|
||||
const result = calculateEloChange(1000, 1000, true);
|
||||
expect(result.player1NewElo).toBe(1016);
|
||||
expect(result.player2NewElo).toBe(984);
|
||||
});
|
||||
|
||||
it('should give larger gain for upset win (lower beats higher)', () => {
|
||||
const upsetWin = calculateEloChange(1000, 1200, true);
|
||||
const expectedWin = calculateEloChange(1200, 1000, true);
|
||||
|
||||
expect(upsetWin.player1Change).toBeGreaterThan(expectedWin.player1Change);
|
||||
});
|
||||
|
||||
it('should give approximately +24 for upset win (200 ELO difference)', () => {
|
||||
const result = calculateEloChange(1000, 1200, true);
|
||||
expect(result.player1Change).toBeCloseTo(24, 0);
|
||||
});
|
||||
|
||||
it('should give approximately +8 for expected win (200 ELO advantage)', () => {
|
||||
const result = calculateEloChange(1200, 1000, true);
|
||||
expect(result.player1Change).toBeCloseTo(8, 0);
|
||||
});
|
||||
|
||||
it('should prevent negative ELO', () => {
|
||||
const result = calculateEloChange(10, 1500, false);
|
||||
expect(result.player1NewElo).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should handle large ELO differences correctly', () => {
|
||||
const result = calculateEloChange(1000, 2000, true);
|
||||
|
||||
expect(result.player1Change).toBeGreaterThan(28);
|
||||
expect(result.player1Change).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it('should round changes to integers', () => {
|
||||
const result = calculateEloChange(1050, 1100, true);
|
||||
|
||||
expect(Number.isInteger(result.player1Change)).toBe(true);
|
||||
expect(Number.isInteger(result.player2Change)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePotentialChange', () => {
|
||||
it('should return positive value for win', () => {
|
||||
const change = calculatePotentialChange(1000, 1000, true);
|
||||
expect(change).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return negative value for loss', () => {
|
||||
const change = calculatePotentialChange(1000, 1000, false);
|
||||
expect(change).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should match player1Change from calculateEloChange for win', () => {
|
||||
const potential = calculatePotentialChange(1000, 1200, true);
|
||||
const full = calculateEloChange(1000, 1200, true);
|
||||
|
||||
expect(potential).toBe(full.player1Change);
|
||||
});
|
||||
|
||||
it('should match player1Change from calculateEloChange for loss', () => {
|
||||
const potential = calculatePotentialChange(1000, 1200, false);
|
||||
const full = calculateEloChange(1000, 1200, false);
|
||||
|
||||
expect(potential).toBe(full.player1Change);
|
||||
});
|
||||
});
|
||||
});
|
||||
640
server/src/game/__tests__/GameSession.test.ts
Normal file
640
server/src/game/__tests__/GameSession.test.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import {
|
||||
GameSessionInstance,
|
||||
GameSessionData,
|
||||
TurnPhase,
|
||||
AttackStep,
|
||||
SessionPlayer,
|
||||
} from '../GameSession';
|
||||
import { EloResult } from '../EloCalculator';
|
||||
import { AuthenticatedSocket } from '../../matchmaking/MatchmakingService';
|
||||
|
||||
// Mock WebSocket
|
||||
const createMockSocket = (userId: string): AuthenticatedSocket => {
|
||||
const messages: string[] = [];
|
||||
return {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: jest.fn((data: string) => messages.push(data)),
|
||||
userId,
|
||||
username: `user_${userId}`,
|
||||
_messages: messages,
|
||||
} as unknown as AuthenticatedSocket;
|
||||
};
|
||||
|
||||
const createPlayer = (
|
||||
userId: string,
|
||||
elo: number = 1000
|
||||
): Omit<SessionPlayer, 'connected'> => ({
|
||||
socket: createMockSocket(userId),
|
||||
userId,
|
||||
username: `Player_${userId}`,
|
||||
deckId: `deck_${userId}`,
|
||||
eloRating: elo,
|
||||
});
|
||||
|
||||
describe('GameSessionInstance', () => {
|
||||
let session: GameSessionInstance;
|
||||
let player1: Omit<SessionPlayer, 'connected'>;
|
||||
let player2: Omit<SessionPlayer, 'connected'>;
|
||||
let mockOnGameEnd: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
player1 = createPlayer('user1');
|
||||
player2 = createPlayer('user2');
|
||||
mockOnGameEnd = jest.fn();
|
||||
session = new GameSessionInstance('game1', player1, player2, 0, mockOnGameEnd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create session with correct initial state', () => {
|
||||
const state = session.getSession();
|
||||
|
||||
expect(state.gameId).toBe('game1');
|
||||
expect(state.currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
expect(state.attackStep).toBe(AttackStep.NONE);
|
||||
expect(state.turnNumber).toBe(1);
|
||||
expect(state.startedAt).toBeNull();
|
||||
expect(state.endedAt).toBeNull();
|
||||
expect(state.winnerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should set first player correctly', () => {
|
||||
const session0 = new GameSessionInstance('g1', player1, player2, 0, mockOnGameEnd);
|
||||
const session1 = new GameSessionInstance('g2', player1, player2, 1, mockOnGameEnd);
|
||||
|
||||
expect(session0.getSession().currentPlayerIndex).toBe(0);
|
||||
expect(session1.getSession().currentPlayerIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should initialize both players as connected', () => {
|
||||
const state = session.getSession();
|
||||
|
||||
expect(state.players[0].connected).toBe(true);
|
||||
expect(state.players[1].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should return correct gameId', () => {
|
||||
expect(session.getGameId()).toBe('game1');
|
||||
});
|
||||
|
||||
it('should not be ended initially', () => {
|
||||
expect(session.isEnded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should set startedAt timestamp', () => {
|
||||
session.start();
|
||||
expect(session.getSession().startedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should broadcast game_start to both players', () => {
|
||||
session.start();
|
||||
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
|
||||
expect(p1Socket.send).toHaveBeenCalled();
|
||||
expect(p2Socket.send).toHaveBeenCalled();
|
||||
|
||||
const p1Message = JSON.parse(p1Socket.send.mock.calls[0][0]);
|
||||
expect(p1Message.type).toBe('game_start');
|
||||
expect(p1Message.payload.your_player_index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action validation - turn order', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
// Advance to MAIN_1 for card actions
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should reject actions from non-participants', () => {
|
||||
const result = session.handleAction('unknown_user', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Player not in this game');
|
||||
});
|
||||
|
||||
it('should reject actions when not players turn (except block)', () => {
|
||||
const result = session.handleAction('user2', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Not your turn');
|
||||
});
|
||||
|
||||
it('should allow actions from current player', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action validation - phase rules', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should reject play_card outside main phases', () => {
|
||||
// In ACTIVE phase
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Can only play cards during Main phases');
|
||||
});
|
||||
|
||||
it('should allow play_card in MAIN_1', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow play_card in MAIN_2', () => {
|
||||
// Advance to MAIN_2
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject attack outside attack phase', () => {
|
||||
const result = session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Can only attack during Attack phase');
|
||||
});
|
||||
|
||||
it('should allow attack in attack phase PREPARATION', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ATTACK);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.PREPARATION);
|
||||
|
||||
const result = session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block outside BLOCK_DECLARATION step', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Cannot declare block now');
|
||||
});
|
||||
|
||||
it('should allow block from defending player in BLOCK_DECLARATION', () => {
|
||||
// Advance to ATTACK phase and declare attack
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.BLOCK_DECLARATION);
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block from attacking player', () => {
|
||||
// Advance to ATTACK phase and declare attack
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user1', 'block', { blocker_instance_id: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Attacker cannot block');
|
||||
});
|
||||
|
||||
it('should reject attack_resolved outside DAMAGE_RESOLUTION', () => {
|
||||
// Advance to ATTACK phase
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user1', 'attack_resolved', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Cannot resolve attack now');
|
||||
});
|
||||
|
||||
it('should allow attack_resolved in DAMAGE_RESOLUTION', () => {
|
||||
// Full attack sequence
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DAMAGE_RESOLUTION);
|
||||
|
||||
const result = session.handleAction('user1', 'attack_resolved', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow concede from either player at any time', () => {
|
||||
const result = session.handleAction('user2', 'concede', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject unknown action types', () => {
|
||||
const result = session.handleAction('user1', 'unknown_action', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Unknown action: unknown_action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload validation', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should validate play_card requires card_instance_id', () => {
|
||||
const result = session.handleAction('user1', 'play_card', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid card_instance_id');
|
||||
});
|
||||
|
||||
it('should validate play_card card_instance_id is number', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid card_instance_id');
|
||||
});
|
||||
|
||||
it('should accept valid play_card payload', () => {
|
||||
const result = session.handleAction('user1', 'play_card', { card_instance_id: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate attack requires attacker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
const result = session.handleAction('user1', 'attack', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid attacker_instance_id');
|
||||
});
|
||||
|
||||
it('should allow block with null blocker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject block with invalid blocker_instance_id', () => {
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
|
||||
const result = session.handleAction('user2', 'block', { blocker_instance_id: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid blocker_instance_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase transitions', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should advance phase on pass action', () => {
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.DRAW);
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.MAIN_1);
|
||||
});
|
||||
|
||||
it('should set attack step to PREPARATION when entering attack phase', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ATTACK);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.PREPARATION);
|
||||
});
|
||||
|
||||
it('should set attack step to NONE when leaving attack phase', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.MAIN_2);
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.NONE);
|
||||
});
|
||||
|
||||
it('should switch players at end of turn', () => {
|
||||
// Complete full turn
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
session.handleAction('user1', 'pass', {}); // ATTACK -> MAIN_2
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_2 -> END
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.END);
|
||||
expect(session.getSession().currentPlayerIndex).toBe(0);
|
||||
|
||||
session.handleAction('user1', 'pass', {}); // END -> new turn
|
||||
|
||||
expect(session.getSession().currentPhase).toBe(TurnPhase.ACTIVE);
|
||||
expect(session.getSession().currentPlayerIndex).toBe(1);
|
||||
expect(session.getSession().turnNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('should broadcast phase changes', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'pass', {});
|
||||
|
||||
const messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const phaseChange = messages.find((m) => m.type === 'phase_changed');
|
||||
|
||||
expect(phaseChange).toBeDefined();
|
||||
expect(phaseChange.payload.phase).toBe(TurnPhase.DRAW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attack flow', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
session.handleAction('user1', 'pass', {}); // MAIN_1 -> ATTACK
|
||||
});
|
||||
|
||||
it('should transition to BLOCK_DECLARATION on attack', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.BLOCK_DECLARATION);
|
||||
});
|
||||
|
||||
it('should transition to DAMAGE_RESOLUTION on block', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: 1 });
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DAMAGE_RESOLUTION);
|
||||
});
|
||||
|
||||
it('should return to DECLARATION on attack_resolved', () => {
|
||||
session.handleAction('user1', 'attack', { attacker_instance_id: 1 });
|
||||
session.handleAction('user2', 'block', { blocker_instance_id: null });
|
||||
session.handleAction('user1', 'attack_resolved', {});
|
||||
|
||||
expect(session.getSession().attackStep).toBe(AttackStep.DECLARATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game end', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should end game on concede', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2');
|
||||
expect(session.getSession().endReason).toBe('concede');
|
||||
});
|
||||
|
||||
it('should end game on timeout', () => {
|
||||
jest.advanceTimersByTime(120000);
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2'); // Player 1 timed out
|
||||
expect(session.getSession().endReason).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should calculate ELO correctly', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalled();
|
||||
const eloResult: EloResult = mockOnGameEnd.mock.calls[0][1];
|
||||
|
||||
expect(eloResult.player2Change).toBeGreaterThan(0); // Winner gains
|
||||
expect(eloResult.player1Change).toBeLessThan(0); // Loser loses
|
||||
});
|
||||
|
||||
it('should broadcast game_ended to both players', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
const p1Messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const p2Messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
|
||||
const p1End = p1Messages.find((m) => m.type === 'game_ended');
|
||||
const p2End = p2Messages.find((m) => m.type === 'game_ended');
|
||||
|
||||
expect(p1End).toBeDefined();
|
||||
expect(p2End).toBeDefined();
|
||||
expect(p1End.payload.is_winner).toBe(false);
|
||||
expect(p2End.payload.is_winner).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject actions after game ended', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
const result = session.handleAction('user2', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Game has ended');
|
||||
});
|
||||
|
||||
it('should call onGameEnd callback', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalledTimes(1);
|
||||
const [sessionData, eloResult] = mockOnGameEnd.mock.calls[0];
|
||||
expect(sessionData.gameId).toBe('game1');
|
||||
expect(eloResult).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect handling', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should mark player as disconnected', () => {
|
||||
session.handleDisconnect('user1');
|
||||
expect(session.getSession().players[0].connected).toBe(false);
|
||||
});
|
||||
|
||||
it('should notify opponent of disconnect', () => {
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const disconnectMsg = messages.find((m) => m.type === 'opponent_disconnected');
|
||||
|
||||
expect(disconnectMsg).toBeDefined();
|
||||
expect(disconnectMsg.payload.reconnect_timeout_seconds).toBe(60);
|
||||
});
|
||||
|
||||
it('should end game after 60s if not reconnected', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user2');
|
||||
expect(session.getSession().endReason).toBe('disconnect');
|
||||
});
|
||||
|
||||
it('should not end game if reconnected before timeout', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
jest.advanceTimersByTime(30000); // 30 seconds
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
jest.advanceTimersByTime(60000); // Past original timeout
|
||||
|
||||
expect(session.isEnded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnect handling', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should restore connection on reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
expect(session.getSession().players[0].connected).toBe(false);
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(session.getSession().players[0].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should sync game state to reconnected player', () => {
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1') as unknown as AuthenticatedSocket & { send: jest.Mock };
|
||||
session.handleReconnect('user1', newSocket);
|
||||
|
||||
const messages = newSocket.send.mock.calls.map((call: [string]) => JSON.parse(call[0]));
|
||||
const syncMsg = messages.find((m: { type: string }) => m.type === 'game_state_sync');
|
||||
|
||||
expect(syncMsg).toBeDefined();
|
||||
expect(syncMsg.payload.current_phase).toBe(TurnPhase.DRAW);
|
||||
});
|
||||
|
||||
it('should notify opponent of reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const reconnectMsg = messages.find((m) => m.type === 'opponent_reconnected');
|
||||
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return false for unknown user reconnect', () => {
|
||||
const newSocket = createMockSocket('unknown');
|
||||
const result = session.handleReconnect('unknown', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for successful reconnect', () => {
|
||||
session.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = session.handleReconnect('user1', newSocket as AuthenticatedSocket);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action relay', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
session.handleAction('user1', 'pass', {}); // ACTIVE -> DRAW
|
||||
session.handleAction('user1', 'pass', {}); // DRAW -> MAIN_1
|
||||
});
|
||||
|
||||
it('should relay action to opponent', () => {
|
||||
const p2Socket = player2.socket as unknown as { send: jest.Mock };
|
||||
p2Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'play_card', { card_instance_id: 42 });
|
||||
|
||||
const messages = p2Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const opponentAction = messages.find((m) => m.type === 'opponent_action');
|
||||
|
||||
expect(opponentAction).toBeDefined();
|
||||
expect(opponentAction.payload.action_type).toBe('play_card');
|
||||
expect(opponentAction.payload.payload.card_instance_id).toBe(42);
|
||||
});
|
||||
|
||||
it('should send confirmation to acting player', () => {
|
||||
const p1Socket = player1.socket as unknown as { send: jest.Mock };
|
||||
p1Socket.send.mockClear();
|
||||
|
||||
session.handleAction('user1', 'play_card', { card_instance_id: 42 });
|
||||
|
||||
const messages = p1Socket.send.mock.calls.map((call) => JSON.parse(call[0]));
|
||||
const confirmation = messages.find((m) => m.type === 'action_confirmed');
|
||||
|
||||
expect(confirmation).toBeDefined();
|
||||
expect(confirmation.payload.action_type).toBe('play_card');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportGameEnd', () => {
|
||||
beforeEach(() => {
|
||||
session.start();
|
||||
});
|
||||
|
||||
it('should end game with provided winner and reason', () => {
|
||||
session.reportGameEnd('user1', 'damage');
|
||||
|
||||
expect(session.isEnded()).toBe(true);
|
||||
expect(session.getSession().winnerId).toBe('user1');
|
||||
expect(session.getSession().endReason).toBe('damage');
|
||||
});
|
||||
|
||||
it('should not end game if already ended', () => {
|
||||
session.handleAction('user1', 'concede', {});
|
||||
mockOnGameEnd.mockClear();
|
||||
|
||||
session.reportGameEnd('user2', 'deck_out');
|
||||
|
||||
expect(mockOnGameEnd).not.toHaveBeenCalled();
|
||||
expect(session.getSession().endReason).toBe('concede'); // Original reason preserved
|
||||
});
|
||||
});
|
||||
});
|
||||
335
server/src/game/__tests__/GameSessionManager.test.ts
Normal file
335
server/src/game/__tests__/GameSessionManager.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { GameSessionManager } from '../GameSessionManager';
|
||||
import { GameSessionData, SessionPlayer, TurnPhase } from '../GameSession';
|
||||
import { EloResult } from '../EloCalculator';
|
||||
import { AuthenticatedSocket } from '../../matchmaking/MatchmakingService';
|
||||
|
||||
// Mock WebSocket
|
||||
const createMockSocket = (userId: string): AuthenticatedSocket => {
|
||||
return {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: jest.fn(),
|
||||
userId,
|
||||
username: `user_${userId}`,
|
||||
} as unknown as AuthenticatedSocket;
|
||||
};
|
||||
|
||||
const createPlayer = (
|
||||
userId: string,
|
||||
elo: number = 1000
|
||||
): Omit<SessionPlayer, 'connected'> => ({
|
||||
socket: createMockSocket(userId),
|
||||
userId,
|
||||
username: `Player_${userId}`,
|
||||
deckId: `deck_${userId}`,
|
||||
eloRating: elo,
|
||||
});
|
||||
|
||||
describe('GameSessionManager', () => {
|
||||
let manager: GameSessionManager;
|
||||
let mockOnGameEnd: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockOnGameEnd = jest.fn().mockResolvedValue(undefined);
|
||||
manager = new GameSessionManager(mockOnGameEnd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.shutdown();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create session and track by game ID', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.getGameId()).toBe('game1');
|
||||
expect(manager.getSessionByGameId('game1')).toBe(session);
|
||||
});
|
||||
|
||||
it('should track user to game mapping', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBe('game1');
|
||||
expect(manager.getGameIdByUserId('user2')).toBe('game1');
|
||||
});
|
||||
|
||||
it('should start the session automatically', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(session.getSession().startedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should clean up existing sessions for players', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
const player3 = createPlayer('user3');
|
||||
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
expect(manager.isInGame('user1')).toBe(true);
|
||||
|
||||
// Create new session with user1 (should cleanup game1)
|
||||
const newPlayer1 = createPlayer('user1');
|
||||
manager.createSession('game2', newPlayer1, player3, 0);
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBe('game2');
|
||||
});
|
||||
|
||||
it('should increment active session count', () => {
|
||||
expect(manager.getActiveSessionCount()).toBe(0);
|
||||
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(1);
|
||||
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAction', () => {
|
||||
let player1: Omit<SessionPlayer, 'connected'>;
|
||||
let player2: Omit<SessionPlayer, 'connected'>;
|
||||
|
||||
beforeEach(() => {
|
||||
player1 = createPlayer('user1');
|
||||
player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
});
|
||||
|
||||
it('should route actions to correct session', () => {
|
||||
const result = manager.handleAction('user1', 'game1', 'pass', {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error for invalid game ID', () => {
|
||||
const result = manager.handleAction('user1', 'nonexistent', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Game session not found');
|
||||
});
|
||||
|
||||
it('should return error if user not in game', () => {
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
// User1 trying to act in game2
|
||||
const result = manager.handleAction('user1', 'game2', 'pass', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Not in this game');
|
||||
});
|
||||
|
||||
it('should forward action to session and return result', () => {
|
||||
// First pass is valid
|
||||
const result1 = manager.handleAction('user1', 'game1', 'pass', {});
|
||||
expect(result1.success).toBe(true);
|
||||
|
||||
// User2 trying to pass (not their turn)
|
||||
const result2 = manager.handleAction('user2', 'game1', 'pass', {});
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.error).toBe('Not your turn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should forward disconnect to session', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session?.getSession().players[0].connected).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing for unknown user', () => {
|
||||
expect(() => manager.handleDisconnect('unknown')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should end game after disconnect timeout', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session).toBeUndefined(); // Session cleaned up after end
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleReconnect', () => {
|
||||
it('should forward reconnect to session', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleDisconnect('user1');
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = manager.handleReconnect('user1', newSocket);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const session = manager.getSessionByGameId('game1');
|
||||
expect(session?.getSession().players[0].connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown user', () => {
|
||||
const newSocket = createMockSocket('unknown');
|
||||
const result = manager.handleReconnect('unknown', newSocket);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if session ended', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
// End game via concede
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
const newSocket = createMockSocket('user1');
|
||||
const result = manager.handleReconnect('user1', newSocket);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionByUserId', () => {
|
||||
it('should return session for user in game', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
const session = manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getSessionByUserId('user1')).toBe(session);
|
||||
expect(manager.getSessionByUserId('user2')).toBe(session);
|
||||
});
|
||||
|
||||
it('should return undefined for user not in game', () => {
|
||||
expect(manager.getSessionByUserId('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInGame', () => {
|
||||
it('should return true for user in active game', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.isInGame('user1')).toBe(true);
|
||||
expect(manager.isInGame('user2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for user not in game', () => {
|
||||
expect(manager.isInGame('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after game ends', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
expect(manager.isInGame('user1')).toBe(false);
|
||||
expect(manager.isInGame('user2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game end handling', () => {
|
||||
it('should clean up mappings on game end', async () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(1);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
// Allow async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(manager.getSessionByGameId('game1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call onGameEnd handler', async () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
|
||||
// Allow async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockOnGameEnd).toHaveBeenCalledTimes(1);
|
||||
const [sessionData, eloResult] = mockOnGameEnd.mock.calls[0];
|
||||
expect(sessionData.gameId).toBe('game1');
|
||||
expect(sessionData.winnerId).toBe('user2');
|
||||
expect(eloResult).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle errors in onGameEnd handler gracefully', async () => {
|
||||
mockOnGameEnd.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
// Should not throw
|
||||
manager.handleAction('user1', 'game1', 'concede', {});
|
||||
await Promise.resolve();
|
||||
|
||||
// Session should still be cleaned up
|
||||
expect(manager.getSessionByGameId('game1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should clear all sessions', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
const player3 = createPlayer('user3');
|
||||
const player4 = createPlayer('user4');
|
||||
manager.createSession('game2', player3, player4, 0);
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(2);
|
||||
|
||||
manager.shutdown();
|
||||
|
||||
expect(manager.getActiveSessionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear user mappings', () => {
|
||||
const player1 = createPlayer('user1');
|
||||
const player2 = createPlayer('user2');
|
||||
manager.createSession('game1', player1, player2, 0);
|
||||
|
||||
manager.shutdown();
|
||||
|
||||
expect(manager.getGameIdByUserId('user1')).toBeUndefined();
|
||||
expect(manager.getGameIdByUserId('user2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
249
server/src/game/__tests__/TurnTimer.test.ts
Normal file
249
server/src/game/__tests__/TurnTimer.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { TurnTimer } from '../TurnTimer';
|
||||
|
||||
describe('TurnTimer', () => {
|
||||
let timer: TurnTimer;
|
||||
let mockTimeout: jest.Mock;
|
||||
let mockBroadcast: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockTimeout = jest.fn();
|
||||
mockBroadcast = jest.fn();
|
||||
timer = new TurnTimer(120000, mockTimeout, mockBroadcast); // 2 minutes
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
timer.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct timeout duration', () => {
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should mark timer as running', () => {
|
||||
timer.start();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should broadcast initial time immediately', () => {
|
||||
timer.start();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith(120);
|
||||
});
|
||||
|
||||
it('should broadcast every second when started', () => {
|
||||
timer.start();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should broadcast decreasing seconds', () => {
|
||||
timer.start();
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(120);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(119);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockBroadcast).toHaveBeenLastCalledWith(118);
|
||||
});
|
||||
|
||||
it('should call onTimeout when timer expires', () => {
|
||||
timer.start();
|
||||
expect(mockTimeout).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(120000);
|
||||
expect(mockTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop timer after timeout', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(120000);
|
||||
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not broadcast after timeout', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(120000);
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop the timer', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop broadcasting when stopped', () => {
|
||||
timer.start();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
timer.stop();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call timeout if stopped before expiry', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // Half way
|
||||
|
||||
timer.stop();
|
||||
jest.advanceTimersByTime(120000); // Past original expiry
|
||||
|
||||
expect(mockTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be safe to call stop when not running', () => {
|
||||
expect(() => timer.stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be safe to call stop multiple times', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(() => timer.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset to full time', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // 60 seconds elapsed
|
||||
expect(timer.getRemainingSeconds()).toBe(60);
|
||||
|
||||
timer.reset();
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should restart broadcasting', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000);
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
timer.reset();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith(120);
|
||||
});
|
||||
|
||||
it('should reset timeout timer', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(60000); // 60 seconds elapsed
|
||||
|
||||
timer.reset();
|
||||
jest.advanceTimersByTime(60000); // 60 more seconds
|
||||
|
||||
expect(mockTimeout).not.toHaveBeenCalled(); // Should have 60 seconds left
|
||||
|
||||
jest.advanceTimersByTime(60000); // Full 120 seconds from reset
|
||||
expect(mockTimeout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('should stop the timer', () => {
|
||||
timer.start();
|
||||
timer.pause();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop broadcasting', () => {
|
||||
timer.start();
|
||||
timer.pause();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemainingSeconds', () => {
|
||||
it('should return full time before start', () => {
|
||||
expect(timer.getRemainingSeconds()).toBe(120);
|
||||
});
|
||||
|
||||
it('should return correct remaining time', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(30000);
|
||||
expect(timer.getRemainingSeconds()).toBe(90);
|
||||
});
|
||||
|
||||
it('should not return negative values', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(150000); // Beyond timeout
|
||||
expect(timer.getRemainingSeconds()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return floor of remaining seconds', () => {
|
||||
timer.start();
|
||||
jest.advanceTimersByTime(500); // Half second
|
||||
expect(timer.getRemainingSeconds()).toBe(119);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('should return false before start', () => {
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after start', () => {
|
||||
timer.start();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after stop', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after reset', () => {
|
||||
timer.start();
|
||||
timer.stop();
|
||||
timer.reset();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('different timeout durations', () => {
|
||||
it('should work with shorter timeouts', () => {
|
||||
const shortTimer = new TurnTimer(5000, mockTimeout, mockBroadcast);
|
||||
shortTimer.start();
|
||||
|
||||
expect(shortTimer.getRemainingSeconds()).toBe(5);
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(mockTimeout).toHaveBeenCalled();
|
||||
|
||||
shortTimer.stop();
|
||||
});
|
||||
|
||||
it('should work with longer timeouts', () => {
|
||||
const longTimer = new TurnTimer(300000, mockTimeout, mockBroadcast);
|
||||
longTimer.start();
|
||||
|
||||
expect(longTimer.getRemainingSeconds()).toBe(300);
|
||||
|
||||
longTimer.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
439
server/src/index.ts
Normal file
439
server/src/index.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { config } from './config.js';
|
||||
import apiRoutes from './api/routes.js';
|
||||
import { verifyToken } from './auth/AuthService.js';
|
||||
import { MatchmakingService, AuthenticatedSocket, MatchResult } from './matchmaking/MatchmakingService.js';
|
||||
import { RoomManager, Room } from './matchmaking/RoomManager.js';
|
||||
import { GameSessionManager } from './game/GameSessionManager.js';
|
||||
import { GameSessionData } from './game/GameSession.js';
|
||||
import { EloResult } from './game/EloCalculator.js';
|
||||
import { getPlayerElo, recordMatchResult } from './db/GameDatabase.js';
|
||||
|
||||
// ============ EXPRESS SERVER (REST API) ============
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
app.listen(config.httpPort, () => {
|
||||
console.log(`HTTP server listening on port ${config.httpPort}`);
|
||||
});
|
||||
|
||||
// ============ WEBSOCKET SERVER ============
|
||||
|
||||
const wss = new WebSocketServer({ port: config.wsPort });
|
||||
|
||||
// Track connected users
|
||||
const connectedUsers = new Map<string, AuthenticatedSocket>();
|
||||
|
||||
// ============ GAME SESSION MANAGER ============
|
||||
|
||||
// Callback when a game ends (for DB updates)
|
||||
async function onGameEnd(session: GameSessionData, eloResult: EloResult): Promise<void> {
|
||||
try {
|
||||
await recordMatchResult(session, eloResult);
|
||||
} catch (error) {
|
||||
console.error('Failed to record match result:', error);
|
||||
// Log the match details even if DB fails so we don't lose the data
|
||||
console.log(
|
||||
`[FALLBACK] Match: ${session.players[0].username} vs ${session.players[1].username}, ` +
|
||||
`winner=${session.winnerId}, reason=${session.endReason}, ` +
|
||||
`ELO: P1=${eloResult.player1NewElo}, P2=${eloResult.player2NewElo}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gameSessionManager = new GameSessionManager(onGameEnd);
|
||||
|
||||
// ============ MATCHMAKING SERVICES ============
|
||||
|
||||
// Callback when a match is found (from queue or room)
|
||||
function onMatchFound(match: MatchResult): void {
|
||||
const gameId = crypto.randomUUID();
|
||||
|
||||
console.log(`Creating game ${gameId}: ${match.player1.username} vs ${match.player2.username}`);
|
||||
|
||||
// Create the game session (this sends game_start to both players)
|
||||
gameSessionManager.createSession(
|
||||
gameId,
|
||||
{
|
||||
socket: match.player1.socket,
|
||||
userId: match.player1.userId,
|
||||
username: match.player1.username,
|
||||
deckId: match.player1.deckId,
|
||||
eloRating: match.player1.eloRating,
|
||||
},
|
||||
{
|
||||
socket: match.player2.socket,
|
||||
userId: match.player2.userId,
|
||||
username: match.player2.username,
|
||||
deckId: match.player2.deckId,
|
||||
eloRating: match.player2.eloRating,
|
||||
},
|
||||
match.firstPlayer
|
||||
);
|
||||
}
|
||||
|
||||
// Callback when room game starts
|
||||
function onRoomGameStart(room: Room): void {
|
||||
if (!room.guest) return;
|
||||
|
||||
const match: MatchResult = {
|
||||
player1: room.host,
|
||||
player2: room.guest,
|
||||
firstPlayer: Math.random() < 0.5 ? 0 : 1,
|
||||
};
|
||||
|
||||
onMatchFound(match);
|
||||
}
|
||||
|
||||
// Initialize matchmaking services
|
||||
const matchmakingService = new MatchmakingService(onMatchFound);
|
||||
const roomManager = new RoomManager(onRoomGameStart);
|
||||
|
||||
// ============ HEARTBEAT ============
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
const socket = ws as AuthenticatedSocket;
|
||||
if (socket.isAlive === false) {
|
||||
console.log(`Terminating inactive connection: ${socket.userId}`);
|
||||
if (socket.userId) {
|
||||
connectedUsers.delete(socket.userId);
|
||||
matchmakingService.handleDisconnect(socket.userId);
|
||||
roomManager.handleDisconnect(socket.userId);
|
||||
gameSessionManager.handleDisconnect(socket.userId);
|
||||
}
|
||||
return socket.terminate();
|
||||
}
|
||||
socket.isAlive = false;
|
||||
socket.ping();
|
||||
});
|
||||
}, config.heartbeatIntervalMs);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
matchmakingService.shutdown();
|
||||
roomManager.shutdown();
|
||||
gameSessionManager.shutdown();
|
||||
});
|
||||
|
||||
// ============ CONNECTION HANDLING ============
|
||||
|
||||
wss.on('connection', (ws: AuthenticatedSocket) => {
|
||||
ws.isAlive = true;
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('Invalid message format:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message format' } }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (ws.userId) {
|
||||
console.log(`User disconnected: ${ws.username} (${ws.userId})`);
|
||||
connectedUsers.delete(ws.userId);
|
||||
matchmakingService.handleDisconnect(ws.userId);
|
||||
roomManager.handleDisconnect(ws.userId);
|
||||
gameSessionManager.handleDisconnect(ws.userId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ MESSAGE HANDLING ============
|
||||
|
||||
interface MessagePayload {
|
||||
token?: string;
|
||||
deck_id?: string;
|
||||
room_code?: string;
|
||||
ready?: boolean;
|
||||
game_id?: string;
|
||||
card_instance_id?: number;
|
||||
attacker_instance_id?: number;
|
||||
blocker_instance_id?: number | null;
|
||||
winner_id?: string;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function handleMessage(ws: AuthenticatedSocket, message: { type: string; payload: MessagePayload }) {
|
||||
const { type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'auth':
|
||||
handleAuth(ws, payload as { token: string });
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong', payload: { serverTime: Date.now() } }));
|
||||
break;
|
||||
|
||||
// ========== MATCHMAKING MESSAGES ==========
|
||||
|
||||
case 'queue_join':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleQueueJoin(ws, payload);
|
||||
break;
|
||||
|
||||
case 'queue_leave':
|
||||
if (!requireAuth(ws)) return;
|
||||
matchmakingService.leaveQueue(ws.userId!);
|
||||
break;
|
||||
|
||||
case 'room_create':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomCreate(ws, payload);
|
||||
break;
|
||||
|
||||
case 'room_join':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomJoin(ws, payload);
|
||||
break;
|
||||
|
||||
case 'room_leave':
|
||||
if (!requireAuth(ws)) return;
|
||||
roomManager.leaveRoom(ws.userId!);
|
||||
break;
|
||||
|
||||
case 'room_ready':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleRoomReady(ws, payload);
|
||||
break;
|
||||
|
||||
// ========== GAME ACTION MESSAGES ==========
|
||||
|
||||
case 'action_play_card':
|
||||
case 'action_attack':
|
||||
case 'action_block':
|
||||
case 'action_pass':
|
||||
case 'action_concede':
|
||||
case 'action_discard_cp':
|
||||
case 'action_dull_backup_cp':
|
||||
case 'action_attack_resolved':
|
||||
case 'action_report_game_end':
|
||||
if (!requireAuth(ws)) return;
|
||||
handleGameAction(ws, type, payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== AUTH HANDLERS ==========
|
||||
|
||||
function requireAuth(ws: AuthenticatedSocket): boolean {
|
||||
if (!ws.userId) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Authentication required' } }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAuth(ws: AuthenticatedSocket, payload: { token: string }) {
|
||||
const { token } = payload;
|
||||
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: 'auth_error', payload: { message: 'Token required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
ws.send(JSON.stringify({ type: 'auth_error', payload: { message: 'Invalid or expired token' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already connected
|
||||
const existingSocket = connectedUsers.get(decoded.userId);
|
||||
if (existingSocket) {
|
||||
// Clean up old connection's state
|
||||
matchmakingService.handleDisconnect(decoded.userId);
|
||||
roomManager.handleDisconnect(decoded.userId);
|
||||
|
||||
// Disconnect old socket
|
||||
existingSocket.send(
|
||||
JSON.stringify({ type: 'disconnected', payload: { message: 'Connected from another location' } })
|
||||
);
|
||||
existingSocket.close();
|
||||
}
|
||||
|
||||
// Check if user was in an active game (reconnection)
|
||||
if (gameSessionManager.isInGame(decoded.userId)) {
|
||||
console.log(`User ${decoded.username} reconnecting to active game`);
|
||||
gameSessionManager.handleReconnect(decoded.userId, ws);
|
||||
}
|
||||
|
||||
// Set user info on socket
|
||||
ws.userId = decoded.userId;
|
||||
ws.username = decoded.username;
|
||||
connectedUsers.set(decoded.userId, ws);
|
||||
|
||||
console.log(`User authenticated: ${decoded.username} (${decoded.userId})`);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'auth_success',
|
||||
payload: {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
inGame: gameSessionManager.isInGame(decoded.userId),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ========== MATCHMAKING HANDLERS ==========
|
||||
|
||||
async function handleQueueJoin(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { deck_id } = payload;
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't join queue if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in queue and room at the same time
|
||||
if (roomManager.isInRoom(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave room before joining queue' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
matchmakingService.joinQueue(ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
async function handleRoomCreate(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { deck_id } = payload;
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't create room if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in room and queue at the same time
|
||||
if (matchmakingService.isInQueue(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave queue before creating room' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
roomManager.createRoom(ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
async function handleRoomJoin(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { room_code, deck_id } = payload;
|
||||
|
||||
if (!room_code) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Room code required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deck_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Deck selection required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't join room if already in a game
|
||||
if (gameSessionManager.isInGame(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Already in a game' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't be in room and queue at the same time
|
||||
if (matchmakingService.isInQueue(ws.userId!)) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Leave queue before joining room' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch actual ELO from database
|
||||
const eloRating = await getPlayerElo(ws.userId!);
|
||||
|
||||
roomManager.joinRoom(room_code, ws, ws.userId!, ws.username!, deck_id, eloRating);
|
||||
}
|
||||
|
||||
function handleRoomReady(ws: AuthenticatedSocket, payload: MessagePayload) {
|
||||
const { ready } = payload;
|
||||
|
||||
if (typeof ready !== 'boolean') {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Ready status required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
roomManager.setReady(ws.userId!, ready);
|
||||
}
|
||||
|
||||
// ========== GAME ACTION HANDLERS ==========
|
||||
|
||||
function handleGameAction(ws: AuthenticatedSocket, type: string, payload: MessagePayload) {
|
||||
const { game_id } = payload;
|
||||
|
||||
if (!game_id) {
|
||||
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Game ID required' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract action type from message type (remove 'action_' prefix)
|
||||
const actionType = type.replace('action_', '');
|
||||
|
||||
// Forward to game session manager
|
||||
const result = gameSessionManager.handleAction(ws.userId!, game_id, actionType, payload);
|
||||
|
||||
if (!result.success) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'action_failed',
|
||||
payload: {
|
||||
action_type: actionType,
|
||||
error: result.error,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ STARTUP ============
|
||||
|
||||
console.log(`WebSocket server listening on port ${config.wsPort}`);
|
||||
console.log(`Environment: ${config.isDev ? 'development' : 'production'}`);
|
||||
207
server/src/matchmaking/MatchmakingService.ts
Normal file
207
server/src/matchmaking/MatchmakingService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
// Extended WebSocket type with user info
|
||||
export interface AuthenticatedSocket extends WebSocket {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
joinedAt: number;
|
||||
eloRange: number; // Starts at 100, expands over time
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
player1: QueuedPlayer;
|
||||
player2: QueuedPlayer;
|
||||
firstPlayer: number; // 0 or 1, randomly chosen
|
||||
}
|
||||
|
||||
type MatchFoundCallback = (match: MatchResult) => void;
|
||||
|
||||
export class MatchmakingService {
|
||||
private queue: Map<string, QueuedPlayer> = new Map();
|
||||
private matchCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ELO range configuration
|
||||
private static readonly INITIAL_ELO_RANGE = 100;
|
||||
private static readonly ELO_RANGE_EXPANSION = 50; // Expand by 50
|
||||
private static readonly ELO_RANGE_EXPANSION_INTERVAL = 30; // Every 30 seconds
|
||||
private static readonly MAX_ELO_RANGE = 500;
|
||||
private static readonly MATCH_CHECK_INTERVAL = 2000; // 2 seconds
|
||||
|
||||
constructor(private onMatchFound: MatchFoundCallback) {
|
||||
// Run match check every 2 seconds
|
||||
this.matchCheckInterval = setInterval(
|
||||
() => this.findMatches(),
|
||||
MatchmakingService.MATCH_CHECK_INTERVAL
|
||||
);
|
||||
console.log('MatchmakingService initialized');
|
||||
}
|
||||
|
||||
joinQueue(
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): boolean {
|
||||
// Remove if already in queue (prevents duplicates)
|
||||
this.leaveQueue(userId);
|
||||
|
||||
const player: QueuedPlayer = {
|
||||
socket,
|
||||
userId,
|
||||
username,
|
||||
deckId,
|
||||
eloRating,
|
||||
joinedAt: Date.now(),
|
||||
eloRange: MatchmakingService.INITIAL_ELO_RANGE,
|
||||
};
|
||||
|
||||
this.queue.set(userId, player);
|
||||
|
||||
// Notify client of queue position
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'queue_joined',
|
||||
payload: {
|
||||
position: this.queue.size,
|
||||
eloRating,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Player ${username} (ELO: ${eloRating}) joined queue. Queue size: ${this.queue.size}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
leaveQueue(userId: string): boolean {
|
||||
const player = this.queue.get(userId);
|
||||
if (player) {
|
||||
this.queue.delete(userId);
|
||||
|
||||
// Only send message if socket is still open
|
||||
if (player.socket.readyState === WebSocket.OPEN) {
|
||||
player.socket.send(JSON.stringify({ type: 'queue_left', payload: {} }));
|
||||
}
|
||||
|
||||
console.log(`Player ${player.username} left queue. Queue size: ${this.queue.size}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private findMatches(): void {
|
||||
if (this.queue.size < 2) {
|
||||
return; // Need at least 2 players to match
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Update ELO ranges for waiting players
|
||||
for (const player of this.queue.values()) {
|
||||
const waitTimeSeconds = (now - player.joinedAt) / 1000;
|
||||
const expansions = Math.floor(waitTimeSeconds / MatchmakingService.ELO_RANGE_EXPANSION_INTERVAL);
|
||||
player.eloRange = Math.min(
|
||||
MatchmakingService.MAX_ELO_RANGE,
|
||||
MatchmakingService.INITIAL_ELO_RANGE + expansions * MatchmakingService.ELO_RANGE_EXPANSION
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to array for iteration
|
||||
const players = Array.from(this.queue.values());
|
||||
const matched = new Set<string>();
|
||||
|
||||
// Find matching pairs
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
if (matched.has(players[i].userId)) continue;
|
||||
|
||||
// Find best match for this player
|
||||
let bestMatch: QueuedPlayer | null = null;
|
||||
let bestEloDiff = Infinity;
|
||||
|
||||
for (let j = i + 1; j < players.length; j++) {
|
||||
if (matched.has(players[j].userId)) continue;
|
||||
|
||||
const eloDiff = Math.abs(players[i].eloRating - players[j].eloRating);
|
||||
const maxRange = Math.max(players[i].eloRange, players[j].eloRange);
|
||||
|
||||
// Check if within acceptable ELO range
|
||||
if (eloDiff <= maxRange && eloDiff < bestEloDiff) {
|
||||
bestMatch = players[j];
|
||||
bestEloDiff = eloDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, create the game
|
||||
if (bestMatch) {
|
||||
matched.add(players[i].userId);
|
||||
matched.add(bestMatch.userId);
|
||||
|
||||
this.queue.delete(players[i].userId);
|
||||
this.queue.delete(bestMatch.userId);
|
||||
|
||||
// Randomly determine first player
|
||||
const firstPlayer = Math.random() < 0.5 ? 0 : 1;
|
||||
|
||||
const matchResult: MatchResult = {
|
||||
player1: players[i],
|
||||
player2: bestMatch,
|
||||
firstPlayer,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Match found: ${players[i].username} (${players[i].eloRating}) vs ` +
|
||||
`${bestMatch.username} (${bestMatch.eloRating}). First player: ${firstPlayer}`
|
||||
);
|
||||
|
||||
this.onMatchFound(matchResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.queue.size;
|
||||
}
|
||||
|
||||
isInQueue(userId: string): boolean {
|
||||
return this.queue.has(userId);
|
||||
}
|
||||
|
||||
getPlayerQueueInfo(userId: string): { position: number; waitTime: number } | null {
|
||||
const player = this.queue.get(userId);
|
||||
if (!player) return null;
|
||||
|
||||
// Calculate position (players are in insertion order)
|
||||
let position = 0;
|
||||
for (const id of this.queue.keys()) {
|
||||
position++;
|
||||
if (id === userId) break;
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
waitTime: Math.floor((Date.now() - player.joinedAt) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
this.leaveQueue(userId);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.matchCheckInterval) {
|
||||
clearInterval(this.matchCheckInterval);
|
||||
this.matchCheckInterval = null;
|
||||
}
|
||||
this.queue.clear();
|
||||
console.log('MatchmakingService shutdown');
|
||||
}
|
||||
}
|
||||
26
server/src/matchmaking/RoomCodeGenerator.ts
Normal file
26
server/src/matchmaking/RoomCodeGenerator.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Room code generator - creates 6-character codes from unambiguous charset
|
||||
// Excludes: 0/O (zero/oh), 1/I/L (one/eye/ell) to avoid confusion
|
||||
|
||||
const CHARSET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
export function generateRoomCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CHARSET[Math.floor(Math.random() * CHARSET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function isValidRoomCode(code: string): boolean {
|
||||
if (!code || code.length !== CODE_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const upperCode = code.toUpperCase();
|
||||
for (const char of upperCode) {
|
||||
if (!CHARSET.includes(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
383
server/src/matchmaking/RoomManager.ts
Normal file
383
server/src/matchmaking/RoomManager.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import { generateRoomCode, isValidRoomCode } from './RoomCodeGenerator.js';
|
||||
import { AuthenticatedSocket } from './MatchmakingService.js';
|
||||
|
||||
export interface RoomPlayer {
|
||||
socket: AuthenticatedSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
deckId: string;
|
||||
eloRating: number;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
code: string;
|
||||
host: RoomPlayer;
|
||||
guest: RoomPlayer | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
code: string;
|
||||
host: { username: string; ready: boolean };
|
||||
guest: { username: string; ready: boolean } | null;
|
||||
}
|
||||
|
||||
type GameStartCallback = (room: Room) => void;
|
||||
|
||||
export class RoomManager {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
private userToRoom: Map<string, string> = new Map(); // userId -> roomCode
|
||||
private expiryInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private static readonly ROOM_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||
private static readonly EXPIRY_CHECK_INTERVAL = 60 * 1000; // 1 minute
|
||||
private static readonly MAX_CODE_GENERATION_ATTEMPTS = 10;
|
||||
|
||||
constructor(private onGameStart: GameStartCallback) {
|
||||
// Check for expired rooms every minute
|
||||
this.expiryInterval = setInterval(
|
||||
() => this.cleanupExpiredRooms(),
|
||||
RoomManager.EXPIRY_CHECK_INTERVAL
|
||||
);
|
||||
console.log('RoomManager initialized');
|
||||
}
|
||||
|
||||
createRoom(
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): Room | null {
|
||||
// Leave any existing room first
|
||||
this.leaveRoom(userId);
|
||||
|
||||
// Generate unique code
|
||||
let code: string = '';
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = generateRoomCode();
|
||||
attempts++;
|
||||
} while (this.rooms.has(code) && attempts < RoomManager.MAX_CODE_GENERATION_ATTEMPTS);
|
||||
|
||||
if (attempts >= RoomManager.MAX_CODE_GENERATION_ATTEMPTS) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Failed to create room. Please try again.' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const room: Room = {
|
||||
code,
|
||||
host: { socket, userId, username, deckId, eloRating, ready: false },
|
||||
guest: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.rooms.set(code, room);
|
||||
this.userToRoom.set(userId, code);
|
||||
|
||||
// Notify the creator
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_created',
|
||||
payload: this.getRoomState(room),
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Room ${code} created by ${username}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
joinRoom(
|
||||
code: string,
|
||||
socket: AuthenticatedSocket,
|
||||
userId: string,
|
||||
username: string,
|
||||
deckId: string,
|
||||
eloRating: number
|
||||
): Room | null {
|
||||
code = code.toUpperCase();
|
||||
|
||||
// Validate code format
|
||||
if (!isValidRoomCode(code)) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Invalid room code format' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
|
||||
if (!room) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Room not found' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if trying to join own room
|
||||
if (room.host.userId === userId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Cannot join your own room' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.guest) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
payload: { message: 'Room is full' },
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Leave any existing room first
|
||||
this.leaveRoom(userId);
|
||||
|
||||
// Add guest to room
|
||||
room.guest = { socket, userId, username, deckId, eloRating, ready: false };
|
||||
this.userToRoom.set(userId, code);
|
||||
|
||||
const roomState = this.getRoomState(room);
|
||||
|
||||
// Notify the guest
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_joined',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
|
||||
// Notify the host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Player ${username} joined room ${code}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
leaveRoom(userId: string): void {
|
||||
const code = this.userToRoom.get(userId);
|
||||
if (!code) return;
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
if (!room) {
|
||||
this.userToRoom.delete(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.host.userId === userId) {
|
||||
// Host leaving - close room entirely
|
||||
console.log(`Host ${room.host.username} left room ${code}, closing room`);
|
||||
|
||||
// Notify guest if present
|
||||
if (room.guest && room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Host left the room' },
|
||||
})
|
||||
);
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
}
|
||||
|
||||
// Notify host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.rooms.delete(code);
|
||||
this.userToRoom.delete(userId);
|
||||
} else if (room.guest?.userId === userId) {
|
||||
// Guest leaving
|
||||
console.log(`Guest ${room.guest.username} left room ${code}`);
|
||||
|
||||
// Notify guest
|
||||
if (room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
room.guest = null;
|
||||
this.userToRoom.delete(userId);
|
||||
|
||||
// Notify host of updated room state
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: this.getRoomState(room),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setReady(userId: string, ready: boolean): void {
|
||||
const code = this.userToRoom.get(userId);
|
||||
if (!code) return;
|
||||
|
||||
const room = this.rooms.get(code);
|
||||
if (!room) return;
|
||||
|
||||
// Update ready status
|
||||
if (room.host.userId === userId) {
|
||||
room.host.ready = ready;
|
||||
} else if (room.guest?.userId === userId) {
|
||||
room.guest.ready = ready;
|
||||
} else {
|
||||
return; // User not in this room
|
||||
}
|
||||
|
||||
const roomState = this.getRoomState(room);
|
||||
|
||||
// Broadcast update to both players
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (room.guest?.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_updated',
|
||||
payload: roomState,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Player in room ${code} set ready: ${ready}`);
|
||||
|
||||
// Check if both players are ready
|
||||
if (room.host.ready && room.guest?.ready) {
|
||||
console.log(`Both players ready in room ${code}, starting game`);
|
||||
|
||||
// Remove room from tracking (game session will take over)
|
||||
this.rooms.delete(code);
|
||||
this.userToRoom.delete(room.host.userId);
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
|
||||
// Trigger game start callback
|
||||
this.onGameStart(room);
|
||||
}
|
||||
}
|
||||
|
||||
getRoomByCode(code: string): Room | undefined {
|
||||
return this.rooms.get(code.toUpperCase());
|
||||
}
|
||||
|
||||
getRoomByUserId(userId: string): Room | undefined {
|
||||
const code = this.userToRoom.get(userId);
|
||||
return code ? this.rooms.get(code) : undefined;
|
||||
}
|
||||
|
||||
isInRoom(userId: string): boolean {
|
||||
return this.userToRoom.has(userId);
|
||||
}
|
||||
|
||||
handleDisconnect(userId: string): void {
|
||||
this.leaveRoom(userId);
|
||||
}
|
||||
|
||||
private getRoomState(room: Room): RoomState {
|
||||
return {
|
||||
code: room.code,
|
||||
host: {
|
||||
username: room.host.username,
|
||||
ready: room.host.ready,
|
||||
},
|
||||
guest: room.guest
|
||||
? {
|
||||
username: room.guest.username,
|
||||
ready: room.guest.ready,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private cleanupExpiredRooms(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [code, room] of this.rooms.entries()) {
|
||||
if (now - room.createdAt > RoomManager.ROOM_EXPIRY_MS) {
|
||||
console.log(`Room ${code} expired, cleaning up`);
|
||||
|
||||
// Notify host
|
||||
if (room.host.socket.readyState === WebSocket.OPEN) {
|
||||
room.host.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Room expired' },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userToRoom.delete(room.host.userId);
|
||||
|
||||
// Notify guest if present
|
||||
if (room.guest) {
|
||||
if (room.guest.socket.readyState === WebSocket.OPEN) {
|
||||
room.guest.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'room_left',
|
||||
payload: { reason: 'Room expired' },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.userToRoom.delete(room.guest.userId);
|
||||
}
|
||||
|
||||
this.rooms.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRoomCount(): number {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.expiryInterval) {
|
||||
clearInterval(this.expiryInterval);
|
||||
this.expiryInterval = null;
|
||||
}
|
||||
this.rooms.clear();
|
||||
this.userToRoom.clear();
|
||||
console.log('RoomManager shutdown');
|
||||
}
|
||||
}
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -528,3 +528,100 @@ func test_block_with_dull_card_fails():
|
||||
var result = game_state.declare_block(blocker)
|
||||
|
||||
assert_false(result)
|
||||
|
||||
|
||||
## ============================================
|
||||
## ONLINE GAME STATE SYNCHRONIZATION TESTS
|
||||
## These test that game state can be updated from
|
||||
## network phase change messages
|
||||
## ============================================
|
||||
|
||||
|
||||
func test_turn_manager_player_index_can_be_set():
|
||||
# Simulates receiving network phase_changed message
|
||||
game_state.start_game(0)
|
||||
|
||||
# Manually set player index like network handler would
|
||||
game_state.turn_manager.current_player_index = 1
|
||||
|
||||
assert_eq(game_state.turn_manager.current_player_index, 1)
|
||||
|
||||
|
||||
func test_turn_manager_phase_can_be_set():
|
||||
# Simulates receiving network phase_changed message
|
||||
game_state.start_game(0)
|
||||
|
||||
# Manually set phase like network handler would
|
||||
game_state.turn_manager.current_phase = Enums.TurnPhase.ATTACK
|
||||
|
||||
assert_eq(game_state.turn_manager.current_phase, Enums.TurnPhase.ATTACK)
|
||||
|
||||
|
||||
func test_turn_manager_turn_number_can_be_set():
|
||||
# Simulates receiving network game_state_sync message
|
||||
game_state.start_game(0)
|
||||
|
||||
# Manually set turn number like network handler would
|
||||
game_state.turn_manager.turn_number = 5
|
||||
|
||||
assert_eq(game_state.turn_manager.turn_number, 5)
|
||||
|
||||
|
||||
func test_turn_manager_initial_values():
|
||||
# Verify initial turn manager state
|
||||
game_state.start_game(0)
|
||||
|
||||
assert_eq(game_state.turn_manager.current_player_index, 0)
|
||||
assert_eq(game_state.turn_manager.turn_number, 1)
|
||||
|
||||
|
||||
func test_turn_manager_attack_step_can_be_set():
|
||||
# For online games, attack step is managed by server
|
||||
game_state.start_game(0)
|
||||
game_state.end_main_phase() # Get to ATTACK phase
|
||||
|
||||
# Manually set attack step like network handler would
|
||||
game_state.turn_manager.attack_step = Enums.AttackStep.BLOCK_DECLARATION
|
||||
|
||||
assert_eq(game_state.turn_manager.attack_step, Enums.AttackStep.BLOCK_DECLARATION)
|
||||
|
||||
|
||||
func test_phase_changed_updates_current_player():
|
||||
# Test that changing phase properly reflects in get_current_player()
|
||||
game_state.start_game(0)
|
||||
|
||||
# Simulate switching turns from network
|
||||
game_state.turn_manager.current_player_index = 1
|
||||
|
||||
var current = game_state.get_current_player()
|
||||
assert_eq(current, game_state.players[1])
|
||||
|
||||
|
||||
func test_phase_changed_updates_opponent():
|
||||
# Test that changing phase properly reflects in get_opponent()
|
||||
game_state.start_game(0)
|
||||
|
||||
# Simulate switching turns from network
|
||||
game_state.turn_manager.current_player_index = 1
|
||||
|
||||
var opponent = game_state.get_opponent()
|
||||
assert_eq(opponent, game_state.players[0])
|
||||
|
||||
|
||||
func test_turn_manager_all_phases_valid():
|
||||
# Verify all TurnPhase enum values can be set
|
||||
game_state.start_game(0)
|
||||
|
||||
for phase in Enums.TurnPhase.values():
|
||||
game_state.turn_manager.current_phase = phase
|
||||
assert_eq(game_state.turn_manager.current_phase, phase)
|
||||
|
||||
|
||||
func test_turn_manager_all_attack_steps_valid():
|
||||
# Verify all AttackStep enum values can be set
|
||||
game_state.start_game(0)
|
||||
game_state.end_main_phase() # Get to ATTACK phase
|
||||
|
||||
for step in Enums.AttackStep.values():
|
||||
game_state.turn_manager.attack_step = step
|
||||
assert_eq(game_state.turn_manager.attack_step, step)
|
||||
|
||||
332
tests/test_ability_processor_conditionals.py
Normal file
332
tests/test_ability_processor_conditionals.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the conditional ability parsing in ability_processor.py
|
||||
|
||||
Run with: python -m pytest tests/test_ability_processor_conditionals.py -v
|
||||
Or directly: python tests/test_ability_processor_conditionals.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Add tools directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
|
||||
|
||||
from ability_processor import (
|
||||
parse_condition,
|
||||
parse_conditional_ability,
|
||||
parse_chained_effect,
|
||||
parse_scaling_effect,
|
||||
parse_effects,
|
||||
)
|
||||
|
||||
|
||||
class TestParseCondition(unittest.TestCase):
|
||||
"""Tests for parse_condition function"""
|
||||
|
||||
def test_control_card_basic(self):
|
||||
"""Should parse 'control [Card Name]' condition"""
|
||||
condition = parse_condition("you control Cloud")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "CONTROL_CARD")
|
||||
self.assertEqual(condition["card_name"], "Cloud")
|
||||
|
||||
def test_control_card_with_prefix(self):
|
||||
"""Should parse 'control a Card Name X' condition"""
|
||||
condition = parse_condition("you control a Card Name Tidus")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "CONTROL_CARD")
|
||||
|
||||
def test_control_count(self):
|
||||
"""Should parse 'control X or more Forwards' condition"""
|
||||
condition = parse_condition("you control 3 or more Forwards")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "CONTROL_COUNT")
|
||||
self.assertEqual(condition["comparison"], "GTE")
|
||||
self.assertEqual(condition["value"], 3)
|
||||
self.assertEqual(condition["card_type"], "FORWARD")
|
||||
|
||||
def test_control_count_backups(self):
|
||||
"""Should parse 'control X or more Backups' condition"""
|
||||
condition = parse_condition("you control 5 or more Backups")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "CONTROL_COUNT")
|
||||
self.assertEqual(condition["value"], 5)
|
||||
self.assertEqual(condition["card_type"], "BACKUP")
|
||||
|
||||
def test_damage_received(self):
|
||||
"""Should parse 'have received X or more damage' condition"""
|
||||
condition = parse_condition("you have received 5 or more damage")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "DAMAGE_RECEIVED")
|
||||
self.assertEqual(condition["comparison"], "GTE")
|
||||
self.assertEqual(condition["value"], 5)
|
||||
|
||||
def test_damage_received_points_of(self):
|
||||
"""Should parse 'X points of damage or more' condition"""
|
||||
condition = parse_condition("you have received 6 points of damage or more")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "DAMAGE_RECEIVED")
|
||||
self.assertEqual(condition["value"], 6)
|
||||
|
||||
def test_break_zone_count(self):
|
||||
"""Should parse break zone count condition"""
|
||||
condition = parse_condition("you have 5 or more Ifrit in your break zone")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "BREAK_ZONE_COUNT")
|
||||
self.assertEqual(condition["comparison"], "GTE")
|
||||
self.assertEqual(condition["value"], 5)
|
||||
|
||||
def test_forward_state_dull(self):
|
||||
"""Should parse 'this forward is dull' condition"""
|
||||
condition = parse_condition("this forward is dull")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "FORWARD_STATE")
|
||||
self.assertEqual(condition["state"], "DULL")
|
||||
self.assertTrue(condition["check_self"])
|
||||
|
||||
def test_forward_state_active(self):
|
||||
"""Should parse 'this forward is active' condition"""
|
||||
condition = parse_condition("this forward is active")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "FORWARD_STATE")
|
||||
self.assertEqual(condition["state"], "ACTIVE")
|
||||
|
||||
def test_cost_comparison_less(self):
|
||||
"""Should parse 'of cost X or less' condition"""
|
||||
condition = parse_condition("of cost 3 or less")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "COST_COMPARISON")
|
||||
self.assertEqual(condition["comparison"], "LTE")
|
||||
self.assertEqual(condition["value"], 3)
|
||||
|
||||
def test_cost_comparison_more(self):
|
||||
"""Should parse 'of cost X or more' condition"""
|
||||
condition = parse_condition("of cost 4 or more")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "COST_COMPARISON")
|
||||
self.assertEqual(condition["comparison"], "GTE")
|
||||
self.assertEqual(condition["value"], 4)
|
||||
|
||||
def test_opponent_no_forwards(self):
|
||||
"""Should parse 'opponent doesn't control any forwards' condition"""
|
||||
condition = parse_condition("your opponent doesn't control any Forwards")
|
||||
|
||||
self.assertIsNotNone(condition)
|
||||
self.assertEqual(condition["type"], "CONTROL_COUNT")
|
||||
self.assertEqual(condition["comparison"], "EQ")
|
||||
self.assertEqual(condition["value"], 0)
|
||||
self.assertEqual(condition["owner"], "OPPONENT")
|
||||
|
||||
def test_unknown_condition(self):
|
||||
"""Should return None for unparseable conditions"""
|
||||
condition = parse_condition("something random and complex")
|
||||
|
||||
self.assertIsNone(condition)
|
||||
|
||||
|
||||
class TestParseConditionalAbility(unittest.TestCase):
|
||||
"""Tests for parse_conditional_ability function"""
|
||||
|
||||
def test_if_control_deal_damage(self):
|
||||
"""Should parse 'If you control X, deal Y damage' pattern"""
|
||||
text = "If you control Cloud, deal 5000 damage to target Forward."
|
||||
|
||||
result = parse_conditional_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CONDITIONAL")
|
||||
self.assertIn("condition", result)
|
||||
self.assertIn("then_effects", result)
|
||||
self.assertTrue(len(result["then_effects"]) > 0)
|
||||
|
||||
def test_if_damage_received_gain_brave(self):
|
||||
"""Should parse damage received condition with ability grant"""
|
||||
text = "If you have received 5 or more damage, this Forward gains Brave."
|
||||
|
||||
result = parse_conditional_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CONDITIONAL")
|
||||
# Even if condition is UNKNOWN, the structure should be correct
|
||||
self.assertIn("condition", result)
|
||||
self.assertIn("then_effects", result)
|
||||
|
||||
def test_not_if_you_do(self):
|
||||
"""Should NOT parse 'If you do so' as conditional (that's chained)"""
|
||||
text = "If you do so, draw 2 cards."
|
||||
|
||||
result = parse_conditional_ability(text)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_non_conditional_text(self):
|
||||
"""Should return None for non-conditional text"""
|
||||
text = "Deal 5000 damage to target Forward."
|
||||
|
||||
result = parse_conditional_ability(text)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestParseChainedEffect(unittest.TestCase):
|
||||
"""Tests for parse_chained_effect function"""
|
||||
|
||||
def test_discard_if_you_do_damage(self):
|
||||
"""Should parse 'Discard 1 card. If you do so, deal damage' pattern"""
|
||||
text = "Discard 1 card from your hand. If you do so, deal 7000 damage to target Forward."
|
||||
|
||||
result = parse_chained_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHAINED_EFFECT")
|
||||
self.assertEqual(result["chain_condition"], "IF_YOU_DO")
|
||||
self.assertIn("primary_effect", result)
|
||||
self.assertIn("chain_effects", result)
|
||||
self.assertEqual(result["primary_effect"]["type"], "DISCARD")
|
||||
|
||||
def test_when_you_do(self):
|
||||
"""Should parse 'When you do so' variant"""
|
||||
text = "Dull 1 Backup. When you do so, draw 1 card."
|
||||
|
||||
result = parse_chained_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHAINED_EFFECT")
|
||||
|
||||
def test_no_chain_pattern(self):
|
||||
"""Should return None for text without chain pattern"""
|
||||
text = "Deal 5000 damage. Draw 1 card."
|
||||
|
||||
result = parse_chained_effect(text)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestParseScalingEffect(unittest.TestCase):
|
||||
"""Tests for parse_scaling_effect function"""
|
||||
|
||||
def test_damage_for_each_damage_received(self):
|
||||
"""Should parse 'Deal X damage for each damage received' pattern"""
|
||||
text = "Deal it 1000 damage for each point of damage you have received."
|
||||
|
||||
result = parse_scaling_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "SCALING_EFFECT")
|
||||
self.assertEqual(result["scale_by"], "DAMAGE_RECEIVED")
|
||||
self.assertEqual(result["multiplier"], 1000)
|
||||
self.assertEqual(result["base_effect"]["type"], "DAMAGE")
|
||||
|
||||
def test_power_for_each_forward(self):
|
||||
"""Should parse '+X power for each Forward you control' pattern"""
|
||||
text = "This Forward gains +1000 power for each Forward you control until the end of the turn."
|
||||
|
||||
result = parse_scaling_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "SCALING_EFFECT")
|
||||
self.assertEqual(result["scale_by"], "FORWARDS_CONTROLLED")
|
||||
self.assertEqual(result["multiplier"], 1000)
|
||||
self.assertEqual(result["base_effect"]["type"], "POWER_MOD")
|
||||
|
||||
def test_draw_for_each_forward(self):
|
||||
"""Should parse 'Draw X cards for each Forward' pattern"""
|
||||
text = "Draw 1 card for each Forward your opponent controls."
|
||||
|
||||
result = parse_scaling_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "SCALING_EFFECT")
|
||||
self.assertEqual(result["multiplier"], 1)
|
||||
self.assertEqual(result["base_effect"]["type"], "DRAW")
|
||||
|
||||
def test_no_scaling_pattern(self):
|
||||
"""Should return None for text without scaling pattern"""
|
||||
text = "Deal 5000 damage to target Forward."
|
||||
|
||||
result = parse_scaling_effect(text)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestParseEffectsIntegration(unittest.TestCase):
|
||||
"""Integration tests for parse_effects with conditionals"""
|
||||
|
||||
def test_chained_effect_detected(self):
|
||||
"""parse_effects should return CHAINED_EFFECT for 'if you do' text"""
|
||||
text = "Discard 1 card from your hand. If you do, draw 2 cards."
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "CHAINED_EFFECT")
|
||||
|
||||
def test_scaling_effect_detected(self):
|
||||
"""parse_effects should return SCALING_EFFECT for 'for each' text"""
|
||||
text = "Deal it 1000 damage for each point of damage you have received."
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "SCALING_EFFECT")
|
||||
|
||||
def test_conditional_effect_detected(self):
|
||||
"""parse_effects should return CONDITIONAL for 'If X, Y' text"""
|
||||
text = "If you control Cloud, deal 5000 damage to target Forward."
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "CONDITIONAL")
|
||||
|
||||
def test_normal_effect_not_conditional(self):
|
||||
"""parse_effects should NOT return CONDITIONAL for normal effects"""
|
||||
text = "Draw 2 cards."
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "DRAW")
|
||||
|
||||
|
||||
class TestRealCardExamples(unittest.TestCase):
|
||||
"""Tests using real card text examples"""
|
||||
|
||||
def test_warrior_damage_scaling(self):
|
||||
"""Test Warrior card with damage scaling"""
|
||||
text = "Deal it 1000 damage for each point of damage you have received."
|
||||
|
||||
result = parse_scaling_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "SCALING_EFFECT")
|
||||
self.assertEqual(result["scale_by"], "DAMAGE_RECEIVED")
|
||||
self.assertEqual(result["multiplier"], 1000)
|
||||
|
||||
def test_zidane_chained_effect(self):
|
||||
"""Test Zidane-style chained effect"""
|
||||
text = "Discard 1 card from your hand. If you do so, choose 1 Forward. Deal it 7000 damage."
|
||||
|
||||
result = parse_chained_effect(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHAINED_EFFECT")
|
||||
self.assertEqual(result["primary_effect"]["type"], "DISCARD")
|
||||
self.assertTrue(len(result["chain_effects"]) > 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
218
tests/test_ability_processor_modal.py
Normal file
218
tests/test_ability_processor_modal.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the modal ability parsing in ability_processor.py
|
||||
|
||||
Run with: python -m pytest tests/test_ability_processor_modal.py -v
|
||||
Or directly: python tests/test_ability_processor_modal.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Add tools directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
|
||||
|
||||
from ability_processor import (
|
||||
extract_modal_actions,
|
||||
parse_modal_ability,
|
||||
parse_effects,
|
||||
parse_field_ability,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractModalActions(unittest.TestCase):
|
||||
"""Tests for extract_modal_actions function"""
|
||||
|
||||
def test_extracts_quoted_actions(self):
|
||||
"""Should extract all quoted action strings"""
|
||||
text = '''Select 1 of the 3 following actions.
|
||||
"Choose 1 Forward. Deal it 7000 damage."
|
||||
"Choose 1 Monster of cost 3 or less. Break it."
|
||||
"Deal 3000 damage to all the Forwards opponent controls."'''
|
||||
|
||||
actions = extract_modal_actions(text)
|
||||
|
||||
self.assertEqual(len(actions), 3)
|
||||
self.assertEqual(actions[0]["index"], 0)
|
||||
self.assertEqual(actions[1]["index"], 1)
|
||||
self.assertEqual(actions[2]["index"], 2)
|
||||
|
||||
def test_preserves_action_descriptions(self):
|
||||
"""Should preserve the full description text"""
|
||||
text = '"Choose 1 Forward. Deal it 7000 damage." "Draw 2 cards."'
|
||||
|
||||
actions = extract_modal_actions(text)
|
||||
|
||||
self.assertEqual(actions[0]["description"], "Choose 1 Forward. Deal it 7000 damage.")
|
||||
self.assertEqual(actions[1]["description"], "Draw 2 cards.")
|
||||
|
||||
def test_parses_effects_for_each_action(self):
|
||||
"""Should parse effects for each action"""
|
||||
text = '"Draw 2 cards." "Deal 5000 damage to all Forwards."'
|
||||
|
||||
actions = extract_modal_actions(text)
|
||||
|
||||
# First action should have DRAW effect
|
||||
self.assertTrue(len(actions[0]["effects"]) > 0)
|
||||
self.assertEqual(actions[0]["effects"][0]["type"], "DRAW")
|
||||
|
||||
def test_handles_empty_text(self):
|
||||
"""Should return empty array for text without quotes"""
|
||||
actions = extract_modal_actions("No quoted text here")
|
||||
self.assertEqual(len(actions), 0)
|
||||
|
||||
|
||||
class TestParseModalAbility(unittest.TestCase):
|
||||
"""Tests for parse_modal_ability function"""
|
||||
|
||||
def test_parses_select_1_of_3(self):
|
||||
"""Should parse 'select 1 of the 3' format"""
|
||||
text = '''Select 1 of the 3 following actions.
|
||||
"Option A" "Option B" "Option C"'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHOOSE_MODE")
|
||||
self.assertEqual(result["select_count"], 1)
|
||||
self.assertEqual(result["mode_count"], 3)
|
||||
self.assertFalse(result["select_up_to"])
|
||||
|
||||
def test_parses_select_up_to(self):
|
||||
"""Should parse 'select up to X' format"""
|
||||
text = '''Select up to 2 of the 4 following actions.
|
||||
"A" "B" "C" "D"'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["select_count"], 2)
|
||||
self.assertEqual(result["mode_count"], 4)
|
||||
self.assertTrue(result["select_up_to"])
|
||||
|
||||
def test_parses_enhanced_condition(self):
|
||||
"""Should parse enhanced/conditional upgrade clause"""
|
||||
text = '''Select 1 of the 3 following actions. If you have 5 or more Ifrit in your Break Zone, select up to 3 of the 3 following actions instead.
|
||||
"A" "B" "C"'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue("enhanced_condition" in result)
|
||||
self.assertEqual(result["enhanced_condition"]["select_count"], 3)
|
||||
self.assertTrue(result["enhanced_condition"]["select_up_to"])
|
||||
|
||||
def test_returns_none_for_non_modal(self):
|
||||
"""Should return None for non-modal text"""
|
||||
text = "Choose 1 Forward. Deal it 5000 damage."
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_extracts_modes_with_effects(self):
|
||||
"""Should extract modes with parsed effects"""
|
||||
text = '''Select 1 of the 2 following actions.
|
||||
"Choose 1 Forward. Deal it 7000 damage."
|
||||
"Draw 2 cards."'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertEqual(len(result["modes"]), 2)
|
||||
# First mode should have damage effect
|
||||
self.assertTrue(len(result["modes"][0]["effects"]) > 0)
|
||||
|
||||
|
||||
class TestParseEffectsModal(unittest.TestCase):
|
||||
"""Tests for parse_effects handling of modal abilities"""
|
||||
|
||||
def test_returns_choose_mode_for_modal_text(self):
|
||||
"""parse_effects should return CHOOSE_MODE for modal text"""
|
||||
text = '''Select 1 of the 2 following actions.
|
||||
"Draw 1 card." "Dull 1 Forward."'''
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "CHOOSE_MODE")
|
||||
|
||||
def test_non_modal_returns_normal_effect(self):
|
||||
"""parse_effects should return normal effects for non-modal text"""
|
||||
text = "Draw 2 cards."
|
||||
|
||||
effects = parse_effects(text)
|
||||
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0]["type"], "DRAW")
|
||||
|
||||
|
||||
class TestParseFieldAbilityModal(unittest.TestCase):
|
||||
"""Tests for parse_field_ability handling of modal abilities"""
|
||||
|
||||
def test_returns_choose_mode_for_modal_field(self):
|
||||
"""parse_field_ability should return CHOOSE_MODE for modal field ability"""
|
||||
text = '''Select 1 of the 3 following actions.
|
||||
"Deal 7000 damage." "Break a Monster." "Deal 3000 to all."'''
|
||||
|
||||
result = parse_field_ability(text, "Test Card")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHOOSE_MODE")
|
||||
|
||||
|
||||
class TestRealCardExamples(unittest.TestCase):
|
||||
"""Tests using real card text examples"""
|
||||
|
||||
def test_ifrita_15_001r(self):
|
||||
"""Test Ifrita card modal ability"""
|
||||
text = '''Select 1 of the 3 following actions. If you have a total of 5 or more Card Name Ifrita and/or Card Name Ifrit in your Break Zone (before paying the cost for Ifrita), select up to 3 of the 3 following actions instead.
|
||||
"Choose 1 Forward. Deal it 7000 damage."
|
||||
"Choose 1 Monster of cost 3 or less. Break it."
|
||||
"Deal 3000 damage to all the Forwards opponent controls."'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["type"], "CHOOSE_MODE")
|
||||
self.assertEqual(result["select_count"], 1)
|
||||
self.assertEqual(result["mode_count"], 3)
|
||||
self.assertEqual(len(result["modes"]), 3)
|
||||
|
||||
# Check enhanced condition
|
||||
self.assertTrue("enhanced_condition" in result)
|
||||
self.assertEqual(result["enhanced_condition"]["select_count"], 3)
|
||||
|
||||
# Check mode descriptions
|
||||
self.assertIn("7000 damage", result["modes"][0]["description"])
|
||||
self.assertIn("Monster", result["modes"][1]["description"])
|
||||
self.assertIn("3000 damage", result["modes"][2]["description"])
|
||||
|
||||
def test_warrior_10_075c(self):
|
||||
"""Test Warrior card modal ability"""
|
||||
text = '''Select 1 of the 2 following actions.
|
||||
"Choose 1 Forward. Until the end of the turn, it gains Brave and it gains +1000 power for each point of damage you have received."
|
||||
"Choose 1 Forward. Deal it 1000 damage for each point of damage you have received."'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["select_count"], 1)
|
||||
self.assertEqual(result["mode_count"], 2)
|
||||
self.assertEqual(len(result["modes"]), 2)
|
||||
|
||||
def test_action_ability_modal(self):
|
||||
"""Test action ability with select up to"""
|
||||
text = '''Select up to 2 of the 4 following actions. "Choose 1 Forward you control. It gains +1000 power until the end of the turn." "All the Forwards you control gain Brave until the end of the turn." "All the Forwards you control gain 'This Forward cannot become dull by your opponent's Summons or abilities' until the end of the turn." "Draw 1 card."'''
|
||||
|
||||
result = parse_modal_ability(text)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["select_count"], 2)
|
||||
self.assertTrue(result["select_up_to"])
|
||||
self.assertEqual(result["mode_count"], 4)
|
||||
self.assertEqual(len(result["modes"]), 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
56
tests/unit/test_ability_system.gd
Normal file
56
tests/unit/test_ability_system.gd
Normal file
@@ -0,0 +1,56 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for the AbilitySystem and related components
|
||||
|
||||
|
||||
func test_target_selector_instantiates() -> void:
|
||||
var selector = TargetSelector.new()
|
||||
assert_not_null(selector, "TargetSelector should instantiate")
|
||||
|
||||
|
||||
func test_trigger_matcher_instantiates() -> void:
|
||||
var matcher = TriggerMatcher.new()
|
||||
assert_not_null(matcher, "TriggerMatcher should instantiate")
|
||||
|
||||
|
||||
func test_effect_resolver_instantiates() -> void:
|
||||
var resolver = EffectResolver.new()
|
||||
assert_not_null(resolver, "EffectResolver should instantiate")
|
||||
|
||||
|
||||
func test_field_effect_manager_instantiates() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
assert_not_null(manager, "FieldEffectManager should instantiate")
|
||||
|
||||
|
||||
func test_field_effect_manager_clear() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
manager.clear_all()
|
||||
assert_eq(manager.get_active_ability_count(), 0, "Should have no active abilities after clear")
|
||||
|
||||
|
||||
func test_target_selector_empty_spec() -> void:
|
||||
var selector = TargetSelector.new()
|
||||
var result = selector.get_valid_targets({}, null, null)
|
||||
assert_eq(result.size(), 0, "Empty spec should return empty array")
|
||||
|
||||
|
||||
func test_trigger_matcher_event_matching() -> void:
|
||||
var matcher = TriggerMatcher.new()
|
||||
|
||||
# Test direct event match
|
||||
assert_true(matcher._event_matches("ATTACKS", "ATTACKS"), "Direct match should work")
|
||||
|
||||
# Test enters field variations
|
||||
assert_true(matcher._event_matches("ENTERS_FIELD", "CARD_PLAYED"), "ENTERS_FIELD should match CARD_PLAYED")
|
||||
|
||||
# Test leaves field variations
|
||||
assert_true(matcher._event_matches("LEAVES_FIELD", "FORWARD_BROKEN"), "LEAVES_FIELD should match FORWARD_BROKEN")
|
||||
|
||||
|
||||
func test_trigger_matcher_source_matching() -> void:
|
||||
var matcher = TriggerMatcher.new()
|
||||
|
||||
# ANY should always match
|
||||
var event_data = {"card": null}
|
||||
assert_true(matcher._source_matches("ANY", event_data, null, null), "ANY source should always match")
|
||||
86
tests/unit/test_attack_step_enum.gd
Normal file
86
tests/unit/test_attack_step_enum.gd
Normal file
@@ -0,0 +1,86 @@
|
||||
extends GutTest
|
||||
|
||||
## Unit tests to verify AttackStep enum alignment between client and server
|
||||
## Server uses: NONE=0, PREPARATION=1, DECLARATION=2, BLOCK_DECLARATION=3, DAMAGE_RESOLUTION=4
|
||||
|
||||
|
||||
## Test that AttackStep enum values match expected server values
|
||||
## Critical for network synchronization
|
||||
|
||||
func test_attack_step_none_equals_zero():
|
||||
assert_eq(Enums.AttackStep.NONE, 0, "AttackStep.NONE should be 0 to match server")
|
||||
|
||||
|
||||
func test_attack_step_preparation_equals_one():
|
||||
assert_eq(Enums.AttackStep.PREPARATION, 1, "AttackStep.PREPARATION should be 1 to match server")
|
||||
|
||||
|
||||
func test_attack_step_declaration_equals_two():
|
||||
assert_eq(Enums.AttackStep.DECLARATION, 2, "AttackStep.DECLARATION should be 2 to match server")
|
||||
|
||||
|
||||
func test_attack_step_block_declaration_equals_three():
|
||||
assert_eq(Enums.AttackStep.BLOCK_DECLARATION, 3, "AttackStep.BLOCK_DECLARATION should be 3 to match server")
|
||||
|
||||
|
||||
func test_attack_step_damage_resolution_equals_four():
|
||||
assert_eq(Enums.AttackStep.DAMAGE_RESOLUTION, 4, "AttackStep.DAMAGE_RESOLUTION should be 4 to match server")
|
||||
|
||||
|
||||
## Test enum ordering is correct
|
||||
func test_attack_step_enum_order():
|
||||
# Verify the enum values are in correct order
|
||||
assert_true(
|
||||
Enums.AttackStep.NONE < Enums.AttackStep.PREPARATION,
|
||||
"NONE should be less than PREPARATION"
|
||||
)
|
||||
assert_true(
|
||||
Enums.AttackStep.PREPARATION < Enums.AttackStep.DECLARATION,
|
||||
"PREPARATION should be less than DECLARATION"
|
||||
)
|
||||
assert_true(
|
||||
Enums.AttackStep.DECLARATION < Enums.AttackStep.BLOCK_DECLARATION,
|
||||
"DECLARATION should be less than BLOCK_DECLARATION"
|
||||
)
|
||||
assert_true(
|
||||
Enums.AttackStep.BLOCK_DECLARATION < Enums.AttackStep.DAMAGE_RESOLUTION,
|
||||
"BLOCK_DECLARATION should be less than DAMAGE_RESOLUTION"
|
||||
)
|
||||
|
||||
|
||||
## Test TurnPhase enum alignment with server as well
|
||||
## Server uses: ACTIVE=0, DRAW=1, MAIN_1=2, ATTACK=3, MAIN_2=4, END=5
|
||||
|
||||
func test_turn_phase_active_equals_zero():
|
||||
assert_eq(Enums.TurnPhase.ACTIVE, 0, "TurnPhase.ACTIVE should be 0 to match server")
|
||||
|
||||
|
||||
func test_turn_phase_draw_equals_one():
|
||||
assert_eq(Enums.TurnPhase.DRAW, 1, "TurnPhase.DRAW should be 1 to match server")
|
||||
|
||||
|
||||
func test_turn_phase_main_1_equals_two():
|
||||
assert_eq(Enums.TurnPhase.MAIN_1, 2, "TurnPhase.MAIN_1 should be 2 to match server")
|
||||
|
||||
|
||||
func test_turn_phase_attack_equals_three():
|
||||
assert_eq(Enums.TurnPhase.ATTACK, 3, "TurnPhase.ATTACK should be 3 to match server")
|
||||
|
||||
|
||||
func test_turn_phase_main_2_equals_four():
|
||||
assert_eq(Enums.TurnPhase.MAIN_2, 4, "TurnPhase.MAIN_2 should be 4 to match server")
|
||||
|
||||
|
||||
func test_turn_phase_end_equals_five():
|
||||
assert_eq(Enums.TurnPhase.END, 5, "TurnPhase.END should be 5 to match server")
|
||||
|
||||
|
||||
## Test that all enum values are present
|
||||
func test_attack_step_has_five_values():
|
||||
var values = Enums.AttackStep.values()
|
||||
assert_eq(values.size(), 5, "AttackStep should have 5 values (NONE, PREPARATION, DECLARATION, BLOCK_DECLARATION, DAMAGE_RESOLUTION)")
|
||||
|
||||
|
||||
func test_turn_phase_has_six_values():
|
||||
var values = Enums.TurnPhase.values()
|
||||
assert_eq(values.size(), 6, "TurnPhase should have 6 values (ACTIVE, DRAW, MAIN_1, ATTACK, MAIN_2, END)")
|
||||
491
tests/unit/test_card_filter.gd
Normal file
491
tests/unit/test_card_filter.gd
Normal file
@@ -0,0 +1,491 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for CardFilter utility class
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - ELEMENT
|
||||
# =============================================================================
|
||||
|
||||
func test_element_filter_matches_fire() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var card = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Fire card should match FIRE filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_element_filter_rejects_wrong_element() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var card = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card, filter), "Ice card should not match FIRE filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_element_filter_case_insensitive() -> void:
|
||||
var filter = {"element": "fire"}
|
||||
var card = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Element filter should be case insensitive")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - JOB
|
||||
# =============================================================================
|
||||
|
||||
func test_job_filter_matches_samurai() -> void:
|
||||
var filter = {"job": "Samurai"}
|
||||
var card = _create_mock_forward_with_job("Samurai")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Samurai should match Samurai filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_job_filter_rejects_wrong_job() -> void:
|
||||
var filter = {"job": "Samurai"}
|
||||
var card = _create_mock_forward_with_job("Warrior")
|
||||
|
||||
assert_false(CardFilter.matches_filter(card, filter), "Warrior should not match Samurai filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_job_filter_case_insensitive() -> void:
|
||||
var filter = {"job": "SAMURAI"}
|
||||
var card = _create_mock_forward_with_job("samurai")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Job filter should be case insensitive")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - CATEGORY
|
||||
# =============================================================================
|
||||
|
||||
func test_category_filter_matches() -> void:
|
||||
var filter = {"category": "VII"}
|
||||
var card = _create_mock_forward_with_category("VII")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "VII card should match VII filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_category_filter_rejects_wrong_category() -> void:
|
||||
var filter = {"category": "VII"}
|
||||
var card = _create_mock_forward_with_category("X")
|
||||
|
||||
assert_false(CardFilter.matches_filter(card, filter), "Category X should not match VII filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_category_filter_matches_partial() -> void:
|
||||
var filter = {"category": "FF"}
|
||||
var card = _create_mock_forward_with_category("FFVII")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "FFVII should match FF filter (partial)")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - COST
|
||||
# =============================================================================
|
||||
|
||||
func test_cost_exact_filter_matches() -> void:
|
||||
var filter = {"cost": 3}
|
||||
var card = _create_mock_forward_with_cost(3)
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Cost 3 should match exact cost 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_exact_filter_rejects_wrong_cost() -> void:
|
||||
var filter = {"cost": 3}
|
||||
var card = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card, filter), "Cost 5 should not match exact cost 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_comparison_lte() -> void:
|
||||
var filter = {"cost_comparison": "LTE", "cost_value": 4}
|
||||
var card3 = _create_mock_forward_with_cost(3)
|
||||
var card4 = _create_mock_forward_with_cost(4)
|
||||
var card5 = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_true(CardFilter.matches_filter(card3, filter), "Cost 3 should match LTE 4")
|
||||
assert_true(CardFilter.matches_filter(card4, filter), "Cost 4 should match LTE 4")
|
||||
assert_false(CardFilter.matches_filter(card5, filter), "Cost 5 should not match LTE 4")
|
||||
|
||||
card3.free()
|
||||
card4.free()
|
||||
card5.free()
|
||||
|
||||
|
||||
func test_cost_comparison_gte() -> void:
|
||||
var filter = {"cost_comparison": "GTE", "cost_value": 4}
|
||||
var card3 = _create_mock_forward_with_cost(3)
|
||||
var card4 = _create_mock_forward_with_cost(4)
|
||||
var card5 = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card3, filter), "Cost 3 should not match GTE 4")
|
||||
assert_true(CardFilter.matches_filter(card4, filter), "Cost 4 should match GTE 4")
|
||||
assert_true(CardFilter.matches_filter(card5, filter), "Cost 5 should match GTE 4")
|
||||
|
||||
card3.free()
|
||||
card4.free()
|
||||
card5.free()
|
||||
|
||||
|
||||
func test_cost_min_max_filters() -> void:
|
||||
var filter = {"cost_min": 3, "cost_max": 5}
|
||||
var card2 = _create_mock_forward_with_cost(2)
|
||||
var card3 = _create_mock_forward_with_cost(3)
|
||||
var card4 = _create_mock_forward_with_cost(4)
|
||||
var card5 = _create_mock_forward_with_cost(5)
|
||||
var card6 = _create_mock_forward_with_cost(6)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card2, filter), "Cost 2 should not be in range 3-5")
|
||||
assert_true(CardFilter.matches_filter(card3, filter), "Cost 3 should be in range 3-5")
|
||||
assert_true(CardFilter.matches_filter(card4, filter), "Cost 4 should be in range 3-5")
|
||||
assert_true(CardFilter.matches_filter(card5, filter), "Cost 5 should be in range 3-5")
|
||||
assert_false(CardFilter.matches_filter(card6, filter), "Cost 6 should not be in range 3-5")
|
||||
|
||||
card2.free()
|
||||
card3.free()
|
||||
card4.free()
|
||||
card5.free()
|
||||
card6.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - CARD TYPE
|
||||
# =============================================================================
|
||||
|
||||
func test_card_type_forward_matches() -> void:
|
||||
var filter = {"card_type": "FORWARD"}
|
||||
var forward = _create_mock_forward()
|
||||
var backup = _create_mock_backup()
|
||||
|
||||
assert_true(CardFilter.matches_filter(forward, filter), "Forward should match FORWARD filter")
|
||||
assert_false(CardFilter.matches_filter(backup, filter), "Backup should not match FORWARD filter")
|
||||
|
||||
forward.free()
|
||||
backup.free()
|
||||
|
||||
|
||||
func test_card_type_backup_matches() -> void:
|
||||
var filter = {"card_type": "BACKUP"}
|
||||
var forward = _create_mock_forward()
|
||||
var backup = _create_mock_backup()
|
||||
|
||||
assert_false(CardFilter.matches_filter(forward, filter), "Forward should not match BACKUP filter")
|
||||
assert_true(CardFilter.matches_filter(backup, filter), "Backup should match BACKUP filter")
|
||||
|
||||
forward.free()
|
||||
backup.free()
|
||||
|
||||
|
||||
func test_card_type_character_matches_both() -> void:
|
||||
var filter = {"card_type": "CHARACTER"}
|
||||
var forward = _create_mock_forward()
|
||||
var backup = _create_mock_backup()
|
||||
|
||||
assert_true(CardFilter.matches_filter(forward, filter), "Forward should match CHARACTER filter")
|
||||
assert_true(CardFilter.matches_filter(backup, filter), "Backup should match CHARACTER filter")
|
||||
|
||||
forward.free()
|
||||
backup.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - NAME
|
||||
# =============================================================================
|
||||
|
||||
func test_card_name_filter_matches() -> void:
|
||||
var filter = {"card_name": "Cloud"}
|
||||
var card = _create_mock_forward_with_name("Cloud")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Cloud should match Cloud filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_name_filter_rejects_wrong_name() -> void:
|
||||
var filter = {"card_name": "Cloud"}
|
||||
var card = _create_mock_forward_with_name("Sephiroth")
|
||||
|
||||
assert_false(CardFilter.matches_filter(card, filter), "Sephiroth should not match Cloud filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_name_filter_alternative_key() -> void:
|
||||
var filter = {"name": "Cloud"}
|
||||
var card = _create_mock_forward_with_name("Cloud")
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Should support 'name' as well as 'card_name'")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS - POWER
|
||||
# =============================================================================
|
||||
|
||||
func test_power_comparison_gte() -> void:
|
||||
var filter = {"power_comparison": "GTE", "power_value": 8000}
|
||||
var card7000 = _create_mock_forward_with_power(7000)
|
||||
var card8000 = _create_mock_forward_with_power(8000)
|
||||
var card9000 = _create_mock_forward_with_power(9000)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card7000, filter), "Power 7000 should not match GTE 8000")
|
||||
assert_true(CardFilter.matches_filter(card8000, filter), "Power 8000 should match GTE 8000")
|
||||
assert_true(CardFilter.matches_filter(card9000, filter), "Power 9000 should match GTE 8000")
|
||||
|
||||
card7000.free()
|
||||
card8000.free()
|
||||
card9000.free()
|
||||
|
||||
|
||||
func test_power_min_max_filters() -> void:
|
||||
var filter = {"power_min": 5000, "power_max": 7000}
|
||||
var card4000 = _create_mock_forward_with_power(4000)
|
||||
var card6000 = _create_mock_forward_with_power(6000)
|
||||
var card8000 = _create_mock_forward_with_power(8000)
|
||||
|
||||
assert_false(CardFilter.matches_filter(card4000, filter), "Power 4000 should not be in range 5000-7000")
|
||||
assert_true(CardFilter.matches_filter(card6000, filter), "Power 6000 should be in range 5000-7000")
|
||||
assert_false(CardFilter.matches_filter(card8000, filter), "Power 8000 should not be in range 5000-7000")
|
||||
|
||||
card4000.free()
|
||||
card6000.free()
|
||||
card8000.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMPTY/NULL FILTER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_empty_filter_matches_all() -> void:
|
||||
var filter = {}
|
||||
var card = _create_mock_forward()
|
||||
|
||||
assert_true(CardFilter.matches_filter(card, filter), "Empty filter should match any card")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_null_card_returns_false() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
|
||||
assert_false(CardFilter.matches_filter(null, filter), "Null card should return false")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMBINED FILTER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_combined_job_and_cost_filter() -> void:
|
||||
var filter = {"job": "Warrior", "cost_comparison": "LTE", "cost_value": 4}
|
||||
|
||||
var cheap_warrior = _create_mock_forward_with_job_and_cost("Warrior", 3)
|
||||
var expensive_warrior = _create_mock_forward_with_job_and_cost("Warrior", 5)
|
||||
var cheap_mage = _create_mock_forward_with_job_and_cost("Mage", 3)
|
||||
|
||||
assert_true(CardFilter.matches_filter(cheap_warrior, filter), "Cheap Warrior should match")
|
||||
assert_false(CardFilter.matches_filter(expensive_warrior, filter), "Expensive Warrior should not match")
|
||||
assert_false(CardFilter.matches_filter(cheap_mage, filter), "Cheap Mage should not match")
|
||||
|
||||
cheap_warrior.free()
|
||||
expensive_warrior.free()
|
||||
cheap_mage.free()
|
||||
|
||||
|
||||
func test_combined_element_and_type_filter() -> void:
|
||||
var filter = {"element": "FIRE", "card_type": "FORWARD"}
|
||||
|
||||
var fire_forward = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
var ice_forward = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
var fire_backup = _create_mock_backup_with_element(Enums.Element.FIRE)
|
||||
|
||||
assert_true(CardFilter.matches_filter(fire_forward, filter), "Fire Forward should match")
|
||||
assert_false(CardFilter.matches_filter(ice_forward, filter), "Ice Forward should not match (wrong element)")
|
||||
assert_false(CardFilter.matches_filter(fire_backup, filter), "Fire Backup should not match (wrong type)")
|
||||
|
||||
fire_forward.free()
|
||||
ice_forward.free()
|
||||
fire_backup.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXCLUDE SELF TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_exclude_self_filter() -> void:
|
||||
var filter = {"card_type": "FORWARD", "exclude_self": true}
|
||||
var source = _create_mock_forward()
|
||||
var other = _create_mock_forward()
|
||||
|
||||
assert_false(CardFilter.matches_filter(source, filter, source), "Source should be excluded")
|
||||
assert_true(CardFilter.matches_filter(other, filter, source), "Other card should not be excluded")
|
||||
|
||||
source.free()
|
||||
other.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COLLECTION METHODS TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_count_matching() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var cards = [
|
||||
_create_mock_forward_with_element(Enums.Element.FIRE),
|
||||
_create_mock_forward_with_element(Enums.Element.ICE),
|
||||
_create_mock_forward_with_element(Enums.Element.FIRE),
|
||||
_create_mock_forward_with_element(Enums.Element.WATER)
|
||||
]
|
||||
|
||||
assert_eq(CardFilter.count_matching(cards, filter), 2, "Should count 2 Fire cards")
|
||||
|
||||
for card in cards:
|
||||
card.free()
|
||||
|
||||
|
||||
func test_get_matching() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var cards = [
|
||||
_create_mock_forward_with_element(Enums.Element.FIRE),
|
||||
_create_mock_forward_with_element(Enums.Element.ICE),
|
||||
_create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
]
|
||||
|
||||
var matching = CardFilter.get_matching(cards, filter)
|
||||
assert_eq(matching.size(), 2, "Should get 2 Fire cards")
|
||||
|
||||
for card in cards:
|
||||
card.free()
|
||||
|
||||
|
||||
func test_get_highest_power() -> void:
|
||||
var cards = [
|
||||
_create_mock_forward_with_power(5000),
|
||||
_create_mock_forward_with_power(9000),
|
||||
_create_mock_forward_with_power(7000)
|
||||
]
|
||||
|
||||
assert_eq(CardFilter.get_highest_power(cards), 9000, "Should return highest power 9000")
|
||||
|
||||
for card in cards:
|
||||
card.free()
|
||||
|
||||
|
||||
func test_get_highest_power_with_filter() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var fire1 = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
fire1.card_data.power = 5000
|
||||
var fire2 = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
fire2.card_data.power = 8000
|
||||
var ice = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
ice.card_data.power = 10000
|
||||
|
||||
var cards = [fire1, fire2, ice]
|
||||
|
||||
assert_eq(CardFilter.get_highest_power(cards, filter), 8000, "Should return highest Fire power 8000")
|
||||
|
||||
for card in cards:
|
||||
card.free()
|
||||
|
||||
|
||||
func test_get_lowest_power() -> void:
|
||||
var cards = [
|
||||
_create_mock_forward_with_power(5000),
|
||||
_create_mock_forward_with_power(3000),
|
||||
_create_mock_forward_with_power(7000)
|
||||
]
|
||||
|
||||
assert_eq(CardFilter.get_lowest_power(cards), 3000, "Should return lowest power 3000")
|
||||
|
||||
for card in cards:
|
||||
card.free()
|
||||
|
||||
|
||||
func test_get_lowest_power_empty_array() -> void:
|
||||
var cards: Array = []
|
||||
|
||||
assert_eq(CardFilter.get_lowest_power(cards), 0, "Should return 0 for empty array")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_forward() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.FORWARD
|
||||
data.cost = 3
|
||||
data.power = 7000
|
||||
data.name = "Test Forward"
|
||||
data.job = "Warrior"
|
||||
data.elements = [Enums.Element.FIRE]
|
||||
card.card_data = data
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_backup() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.BACKUP
|
||||
data.cost = 2
|
||||
data.name = "Test Backup"
|
||||
data.job = "Scholar"
|
||||
data.elements = [Enums.Element.WATER]
|
||||
card.card_data = data
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_element(element: Enums.Element) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.elements = [element]
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_backup_with_element(element: Enums.Element) -> CardInstance:
|
||||
var card = _create_mock_backup()
|
||||
card.card_data.elements = [element]
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_job(job: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.job = job
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_category(category: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.category = category
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_cost(cost: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.cost = cost
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_name(card_name: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.name = card_name
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_power(power: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.power = power
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_job_and_cost(job: String, cost: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.job = job
|
||||
card.card_data.cost = cost
|
||||
return card
|
||||
420
tests/unit/test_condition_checker.gd
Normal file
420
tests/unit/test_condition_checker.gd
Normal file
@@ -0,0 +1,420 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for the ConditionChecker class
|
||||
## Tests various condition types and logical operators
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SETUP
|
||||
# =============================================================================
|
||||
|
||||
var checker: ConditionChecker
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
checker = ConditionChecker.new()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BASIC TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_checker_instantiates() -> void:
|
||||
assert_not_null(checker, "ConditionChecker should instantiate")
|
||||
|
||||
|
||||
func test_empty_condition_returns_true() -> void:
|
||||
var result = checker.evaluate({}, {})
|
||||
assert_true(result, "Empty condition should return true (unconditional)")
|
||||
|
||||
|
||||
func test_unknown_condition_type_returns_false() -> void:
|
||||
var condition = {"type": "NONEXISTENT_TYPE"}
|
||||
var result = checker.evaluate(condition, {})
|
||||
assert_false(result, "Unknown condition type should return false")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMPARISON HELPER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_compare_eq() -> void:
|
||||
assert_true(checker._compare(5, "EQ", 5), "5 == 5")
|
||||
assert_false(checker._compare(5, "EQ", 3), "5 != 3")
|
||||
|
||||
|
||||
func test_compare_neq() -> void:
|
||||
assert_true(checker._compare(5, "NEQ", 3), "5 != 3")
|
||||
assert_false(checker._compare(5, "NEQ", 5), "5 == 5")
|
||||
|
||||
|
||||
func test_compare_gt() -> void:
|
||||
assert_true(checker._compare(5, "GT", 3), "5 > 3")
|
||||
assert_false(checker._compare(5, "GT", 5), "5 not > 5")
|
||||
assert_false(checker._compare(3, "GT", 5), "3 not > 5")
|
||||
|
||||
|
||||
func test_compare_gte() -> void:
|
||||
assert_true(checker._compare(5, "GTE", 3), "5 >= 3")
|
||||
assert_true(checker._compare(5, "GTE", 5), "5 >= 5")
|
||||
assert_false(checker._compare(3, "GTE", 5), "3 not >= 5")
|
||||
|
||||
|
||||
func test_compare_lt() -> void:
|
||||
assert_true(checker._compare(3, "LT", 5), "3 < 5")
|
||||
assert_false(checker._compare(5, "LT", 5), "5 not < 5")
|
||||
assert_false(checker._compare(5, "LT", 3), "5 not < 3")
|
||||
|
||||
|
||||
func test_compare_lte() -> void:
|
||||
assert_true(checker._compare(3, "LTE", 5), "3 <= 5")
|
||||
assert_true(checker._compare(5, "LTE", 5), "5 <= 5")
|
||||
assert_false(checker._compare(5, "LTE", 3), "5 not <= 3")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LOGICAL OPERATOR TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_and_with_all_true() -> void:
|
||||
var condition = {
|
||||
"type": "AND",
|
||||
"conditions": [
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 3},
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "LTE", "value": 5}
|
||||
]
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(4)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_true(result, "AND with all true conditions should return true")
|
||||
|
||||
|
||||
func test_and_with_one_false() -> void:
|
||||
var condition = {
|
||||
"type": "AND",
|
||||
"conditions": [
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 3},
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "LTE", "value": 2}
|
||||
]
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(4)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_false(result, "AND with one false condition should return false")
|
||||
|
||||
|
||||
func test_or_with_one_true() -> void:
|
||||
var condition = {
|
||||
"type": "OR",
|
||||
"conditions": [
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 10},
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 3}
|
||||
]
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(5)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_true(result, "OR with one true condition should return true")
|
||||
|
||||
|
||||
func test_or_with_all_false() -> void:
|
||||
var condition = {
|
||||
"type": "OR",
|
||||
"conditions": [
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 10},
|
||||
{"type": "DAMAGE_RECEIVED", "comparison": "EQ", "value": 0}
|
||||
]
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(5)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_false(result, "OR with all false conditions should return false")
|
||||
|
||||
|
||||
func test_not_inverts_true() -> void:
|
||||
var condition = {
|
||||
"type": "NOT",
|
||||
"condition": {"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 10}
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(5)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_true(result, "NOT should invert false to true")
|
||||
|
||||
|
||||
func test_not_inverts_false() -> void:
|
||||
var condition = {
|
||||
"type": "NOT",
|
||||
"condition": {"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 3}
|
||||
}
|
||||
|
||||
var context = _create_mock_context_with_damage(5)
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_false(result, "NOT should invert true to false")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DAMAGE_RECEIVED CONDITION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_damage_received_gte() -> void:
|
||||
var condition = {
|
||||
"type": "DAMAGE_RECEIVED",
|
||||
"comparison": "GTE",
|
||||
"value": 5
|
||||
}
|
||||
|
||||
# Not enough damage
|
||||
var context = _create_mock_context_with_damage(3)
|
||||
assert_false(checker.evaluate(condition, context), "3 damage not >= 5")
|
||||
|
||||
# Exactly enough
|
||||
context = _create_mock_context_with_damage(5)
|
||||
assert_true(checker.evaluate(condition, context), "5 damage >= 5")
|
||||
|
||||
# More than enough
|
||||
context = _create_mock_context_with_damage(7)
|
||||
assert_true(checker.evaluate(condition, context), "7 damage >= 5")
|
||||
|
||||
|
||||
func test_damage_received_no_game_state() -> void:
|
||||
var condition = {
|
||||
"type": "DAMAGE_RECEIVED",
|
||||
"comparison": "GTE",
|
||||
"value": 5
|
||||
}
|
||||
|
||||
var context = {"game_state": null, "player_id": 0}
|
||||
var result = checker.evaluate(condition, context)
|
||||
assert_false(result, "Should return false without game state")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FORWARD STATE TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_forward_state_dull() -> void:
|
||||
var condition = {
|
||||
"type": "FORWARD_STATE",
|
||||
"state": "DULL",
|
||||
"check_self": false
|
||||
}
|
||||
|
||||
# Create mock card
|
||||
var card = _create_mock_forward()
|
||||
card.is_dull = true
|
||||
|
||||
var context = {"target_card": card}
|
||||
assert_true(checker.evaluate(condition, context), "Dull card should match DULL state")
|
||||
|
||||
card.is_dull = false
|
||||
assert_false(checker.evaluate(condition, context), "Active card should not match DULL state")
|
||||
|
||||
card.free()
|
||||
|
||||
|
||||
func test_forward_state_active() -> void:
|
||||
var condition = {
|
||||
"type": "FORWARD_STATE",
|
||||
"state": "ACTIVE",
|
||||
"check_self": false
|
||||
}
|
||||
|
||||
var card = _create_mock_forward()
|
||||
card.is_dull = false
|
||||
|
||||
var context = {"target_card": card}
|
||||
assert_true(checker.evaluate(condition, context), "Active card should match ACTIVE state")
|
||||
|
||||
card.is_dull = true
|
||||
assert_false(checker.evaluate(condition, context), "Dull card should not match ACTIVE state")
|
||||
|
||||
card.free()
|
||||
|
||||
|
||||
func test_forward_state_check_self() -> void:
|
||||
var condition = {
|
||||
"type": "FORWARD_STATE",
|
||||
"state": "DULL",
|
||||
"check_self": true
|
||||
}
|
||||
|
||||
var source_card = _create_mock_forward()
|
||||
source_card.is_dull = true
|
||||
|
||||
var context = {"source_card": source_card, "target_card": null}
|
||||
assert_true(checker.evaluate(condition, context), "check_self=true should use source_card")
|
||||
|
||||
source_card.free()
|
||||
|
||||
|
||||
func test_forward_state_no_card() -> void:
|
||||
var condition = {
|
||||
"type": "FORWARD_STATE",
|
||||
"state": "DULL",
|
||||
"check_self": false
|
||||
}
|
||||
|
||||
var context = {"target_card": null}
|
||||
assert_false(checker.evaluate(condition, context), "Should return false without target card")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COST COMPARISON TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_cost_comparison_lte() -> void:
|
||||
var condition = {
|
||||
"type": "COST_COMPARISON",
|
||||
"comparison": "LTE",
|
||||
"value": 3
|
||||
}
|
||||
|
||||
var card = _create_mock_forward_with_cost(2)
|
||||
var context = {"target_card": card}
|
||||
assert_true(checker.evaluate(condition, context), "Cost 2 <= 3")
|
||||
|
||||
card.card_data.cost = 3
|
||||
assert_true(checker.evaluate(condition, context), "Cost 3 <= 3")
|
||||
|
||||
card.card_data.cost = 5
|
||||
assert_false(checker.evaluate(condition, context), "Cost 5 not <= 3")
|
||||
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_comparison_gte() -> void:
|
||||
var condition = {
|
||||
"type": "COST_COMPARISON",
|
||||
"comparison": "GTE",
|
||||
"value": 4
|
||||
}
|
||||
|
||||
var card = _create_mock_forward_with_cost(5)
|
||||
var context = {"target_card": card}
|
||||
assert_true(checker.evaluate(condition, context), "Cost 5 >= 4")
|
||||
|
||||
card.card_data.cost = 4
|
||||
assert_true(checker.evaluate(condition, context), "Cost 4 >= 4")
|
||||
|
||||
card.card_data.cost = 3
|
||||
assert_false(checker.evaluate(condition, context), "Cost 3 not >= 4")
|
||||
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POWER COMPARISON TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_power_comparison_lt_value() -> void:
|
||||
var condition = {
|
||||
"type": "POWER_COMPARISON",
|
||||
"comparison": "LT",
|
||||
"value": 8000
|
||||
}
|
||||
|
||||
var card = _create_mock_forward_with_power(5000)
|
||||
var context = {"target_card": card}
|
||||
assert_true(checker.evaluate(condition, context), "Power 5000 < 8000")
|
||||
|
||||
card.current_power = 8000
|
||||
assert_false(checker.evaluate(condition, context), "Power 8000 not < 8000")
|
||||
|
||||
card.free()
|
||||
|
||||
|
||||
func test_power_comparison_self_power() -> void:
|
||||
var condition = {
|
||||
"type": "POWER_COMPARISON",
|
||||
"comparison": "LT",
|
||||
"compare_to": "SELF_POWER"
|
||||
}
|
||||
|
||||
var attacker = _create_mock_forward_with_power(8000)
|
||||
var blocker = _create_mock_forward_with_power(5000)
|
||||
|
||||
var context = {
|
||||
"source_card": attacker,
|
||||
"target_card": blocker
|
||||
}
|
||||
|
||||
assert_true(checker.evaluate(condition, context), "Blocker 5000 < Attacker 8000")
|
||||
|
||||
blocker.current_power = 9000
|
||||
assert_false(checker.evaluate(condition, context), "Blocker 9000 not < Attacker 8000")
|
||||
|
||||
attacker.free()
|
||||
blocker.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_context_with_damage(damage: int) -> Dictionary:
|
||||
var mock_game_state = MockGameState.new(damage)
|
||||
return {
|
||||
"game_state": mock_game_state,
|
||||
"player_id": 0
|
||||
}
|
||||
|
||||
|
||||
func _create_mock_forward() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.FORWARD
|
||||
data.cost = 3
|
||||
data.power = 7000
|
||||
card.card_data = data
|
||||
card.is_dull = false
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_cost(cost: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.cost = cost
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_power(power: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.power = power
|
||||
card.current_power = power
|
||||
return card
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK CLASSES
|
||||
# =============================================================================
|
||||
|
||||
class MockGameState:
|
||||
var players: Array
|
||||
|
||||
func _init(player_damage: int = 0):
|
||||
players = [MockPlayer.new(player_damage), MockPlayer.new(0)]
|
||||
|
||||
func get_player_damage(player_id: int) -> int:
|
||||
if player_id < players.size():
|
||||
return players[player_id].damage
|
||||
return 0
|
||||
|
||||
func get_field_cards(player_id: int) -> Array:
|
||||
if player_id < players.size():
|
||||
return players[player_id].field
|
||||
return []
|
||||
|
||||
func get_break_zone(player_id: int) -> Array:
|
||||
if player_id < players.size():
|
||||
return players[player_id].break_zone
|
||||
return []
|
||||
|
||||
|
||||
class MockPlayer:
|
||||
var damage: int = 0
|
||||
var field: Array = []
|
||||
var break_zone: Array = []
|
||||
|
||||
func _init(p_damage: int = 0):
|
||||
damage = p_damage
|
||||
348
tests/unit/test_cost_validation.gd
Normal file
348
tests/unit/test_cost_validation.gd
Normal file
@@ -0,0 +1,348 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for ability cost validation in AbilitySystem
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CP COST VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_validate_empty_cost_always_valid() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {}
|
||||
var player = MockPlayer.new()
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Empty cost should always be valid")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_cp_cost_with_sufficient_cp() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 3}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 5)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid with sufficient CP")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_cp_cost_with_insufficient_cp() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 5}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 3)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_false(result.valid, "Should be invalid with insufficient CP")
|
||||
assert_true("Not enough CP" in result.reason, "Reason should mention CP")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_specific_element_cp_cost_valid() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "element": "FIRE"}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 3)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid with sufficient Fire CP")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_specific_element_cp_cost_invalid() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "element": "FIRE"}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.ICE, 5) # Wrong element
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_false(result.valid, "Should be invalid with wrong element CP")
|
||||
assert_true("FIRE CP" in result.reason, "Reason should mention Fire")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_any_element_cp_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 4, "element": "ANY"}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 2)
|
||||
player.cp_pool.add_cp(Enums.Element.ICE, 2)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid with total CP from multiple elements")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DISCARD COST VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_validate_discard_cost_with_sufficient_cards() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"discard": 2}
|
||||
var player = MockPlayer.new()
|
||||
# Add 3 cards to hand
|
||||
for i in range(3):
|
||||
var card = _create_mock_card()
|
||||
player.hand.add_card(card)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid with sufficient cards to discard")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_discard_cost_with_insufficient_cards() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"discard": 3}
|
||||
var player = MockPlayer.new()
|
||||
# Add only 1 card to hand
|
||||
player.hand.add_card(_create_mock_card())
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_false(result.valid, "Should be invalid with insufficient cards")
|
||||
assert_true("discard" in result.reason, "Reason should mention discard")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DULL SELF COST VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_validate_dull_self_cost_when_active() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.activate() # Ensure active
|
||||
var player = MockPlayer.new()
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, source, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid when card is active")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_dull_self_cost_when_already_dull() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.dull() # Already dull
|
||||
var player = MockPlayer.new()
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, source, player)
|
||||
|
||||
assert_false(result.valid, "Should be invalid when card is already dull")
|
||||
assert_true("dulled" in result.reason, "Reason should mention dulled")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SPECIFIC DISCARD COST VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_validate_specific_discard_cost_with_matching_card() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"specific_discard": "Cloud"}
|
||||
var player = MockPlayer.new()
|
||||
var cloud = _create_mock_card_with_name("Cloud")
|
||||
player.hand.add_card(cloud)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid with matching card in hand")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_specific_discard_cost_without_matching_card() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"specific_discard": "Cloud"}
|
||||
var player = MockPlayer.new()
|
||||
var sephiroth = _create_mock_card_with_name("Sephiroth")
|
||||
player.hand.add_card(sephiroth)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, null, player)
|
||||
|
||||
assert_false(result.valid, "Should be invalid without matching card")
|
||||
assert_true("Cloud" in result.reason, "Reason should mention required card")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMBINED COST VALIDATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_validate_combined_cp_and_dull_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.activate()
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 3)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, source, player)
|
||||
|
||||
assert_true(result.valid, "Should be valid when all requirements met")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_combined_cost_fails_on_cp() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 5, "dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.activate()
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 2) # Insufficient
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, source, player)
|
||||
|
||||
assert_false(result.valid, "Should fail on CP requirement")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_validate_combined_cost_fails_on_dull() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.dull() # Already dull
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 5)
|
||||
|
||||
var result = ability_system._validate_ability_cost(cost, source, player)
|
||||
|
||||
assert_false(result.valid, "Should fail on dull requirement")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COST PAYMENT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_pay_empty_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {}
|
||||
var player = MockPlayer.new()
|
||||
|
||||
var success = ability_system._pay_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(success, "Paying empty cost should succeed")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_pay_generic_cp_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 3}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 5)
|
||||
|
||||
var initial_cp = player.cp_pool.get_total_cp()
|
||||
var success = ability_system._pay_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(success, "Should successfully pay cost")
|
||||
assert_eq(player.cp_pool.get_total_cp(), initial_cp - 3, "Should spend 3 CP")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_pay_specific_element_cp_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "element": "FIRE"}
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 4)
|
||||
player.cp_pool.add_cp(Enums.Element.ICE, 3)
|
||||
|
||||
var success = ability_system._pay_ability_cost(cost, null, player)
|
||||
|
||||
assert_true(success, "Should successfully pay cost")
|
||||
assert_eq(player.cp_pool.get_cp(Enums.Element.FIRE), 2, "Should spend 2 Fire CP")
|
||||
assert_eq(player.cp_pool.get_cp(Enums.Element.ICE), 3, "Ice CP should be unchanged")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_pay_dull_self_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.activate()
|
||||
var player = MockPlayer.new()
|
||||
|
||||
assert_true(source.is_active(), "Source should start active")
|
||||
|
||||
var success = ability_system._pay_ability_cost(cost, source, player)
|
||||
|
||||
assert_true(success, "Should successfully pay cost")
|
||||
assert_true(source.is_dull(), "Source should be dulled")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_pay_combined_cost() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var cost = {"cp": 2, "dull_self": true}
|
||||
var source = _create_mock_card()
|
||||
source.activate()
|
||||
var player = MockPlayer.new()
|
||||
player.cp_pool.add_cp(Enums.Element.FIRE, 5)
|
||||
|
||||
var success = ability_system._pay_ability_cost(cost, source, player)
|
||||
|
||||
assert_true(success, "Should successfully pay combined cost")
|
||||
assert_eq(player.cp_pool.get_total_cp(), 3, "Should spend 2 CP")
|
||||
assert_true(source.is_dull(), "Source should be dulled")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SIGNAL TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_ability_cost_failed_signal_declared() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
|
||||
# Check signal exists
|
||||
var signal_exists = ability_system.has_signal("ability_cost_failed")
|
||||
assert_true(signal_exists, "AbilitySystem should have ability_cost_failed signal")
|
||||
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_card() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.FORWARD
|
||||
data.power = 5000
|
||||
data.cost = 3
|
||||
data.name = "Test Card"
|
||||
data.elements = [Enums.Element.FIRE]
|
||||
card.card_data = data
|
||||
card.current_power = 5000
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_card_with_name(card_name: String) -> CardInstance:
|
||||
var card = _create_mock_card()
|
||||
card.card_data.name = card_name
|
||||
return card
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK CLASSES
|
||||
# =============================================================================
|
||||
|
||||
class MockPlayer:
|
||||
var cp_pool: CPPool
|
||||
var hand: Zone
|
||||
|
||||
func _init():
|
||||
cp_pool = CPPool.new()
|
||||
hand = Zone.new(Enums.ZoneType.HAND, 0)
|
||||
751
tests/unit/test_effect_resolver.gd
Normal file
751
tests/unit/test_effect_resolver.gd
Normal file
@@ -0,0 +1,751 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for EffectResolver effect execution
|
||||
## Covers core effects: DAMAGE, POWER_MOD, DULL, ACTIVATE, DRAW, BREAK, RETURN
|
||||
|
||||
|
||||
var resolver: EffectResolver
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
resolver = EffectResolver.new()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DAMAGE EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_damage_basic() -> void:
|
||||
var card = _create_mock_forward(7000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
var effect = {"type": "DAMAGE", "amount": 3000}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_eq(card.damage_received, 3000, "Card should have received 3000 damage")
|
||||
|
||||
|
||||
func test_resolve_damage_breaks_forward() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
var effect = {"type": "DAMAGE", "amount": 5000}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(game_state.players[0].break_zone.has_card(card), "Forward should be in break zone")
|
||||
assert_false(game_state.players[0].field_forwards.has_card(card), "Forward should not be on field")
|
||||
|
||||
|
||||
func test_resolve_damage_exact_power_breaks() -> void:
|
||||
var card = _create_mock_forward(6000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
var effect = {"type": "DAMAGE", "amount": 6000}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(game_state.players[0].break_zone.has_card(card), "Forward should break when damage equals power")
|
||||
|
||||
|
||||
func test_resolve_damage_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(8000)
|
||||
var card2 = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].field_forwards.add_card(card1)
|
||||
game_state.players[0].field_forwards.add_card(card2)
|
||||
card1.controller_index = 0
|
||||
card2.controller_index = 0
|
||||
|
||||
var effect = {"type": "DAMAGE", "amount": 3000}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_eq(card1.damage_received, 3000, "First card should have 3000 damage")
|
||||
assert_eq(card2.damage_received, 3000, "Second card should have 3000 damage")
|
||||
|
||||
|
||||
func test_resolve_damage_ignores_non_forwards() -> void:
|
||||
var backup = _create_mock_backup()
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "DAMAGE", "amount": 5000}
|
||||
|
||||
resolver.resolve(effect, null, [backup], game_state)
|
||||
|
||||
assert_eq(backup.damage_received, 0, "Backup should not receive damage")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POWER_MOD EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_power_mod_positive() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "POWER_MOD", "amount": 2000}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_eq(card.power_modifiers.size(), 1, "Should have one power modifier")
|
||||
assert_eq(card.power_modifiers[0], 2000, "Power modifier should be +2000")
|
||||
|
||||
|
||||
func test_resolve_power_mod_negative() -> void:
|
||||
var card = _create_mock_forward(7000)
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "POWER_MOD", "amount": -3000}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_eq(card.power_modifiers[0], -3000, "Power modifier should be -3000")
|
||||
|
||||
|
||||
func test_resolve_power_mod_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(5000)
|
||||
var card2 = _create_mock_forward(6000)
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "POWER_MOD", "amount": 1000}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_eq(card1.power_modifiers[0], 1000, "First card should have +1000")
|
||||
assert_eq(card2.power_modifiers[0], 1000, "Second card should have +1000")
|
||||
|
||||
|
||||
func test_resolve_power_mod_applies_to_source_if_no_targets() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "POWER_MOD", "amount": 3000}
|
||||
|
||||
resolver.resolve(effect, source, [], game_state)
|
||||
|
||||
assert_eq(source.power_modifiers[0], 3000, "Source should get modifier when no targets")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DULL EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_dull_single_target() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
card.activate() # Ensure card starts active
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "DULL"}
|
||||
|
||||
assert_true(card.is_active(), "Card should start active")
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(card.is_dull(), "Card should be dulled")
|
||||
|
||||
|
||||
func test_resolve_dull_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(5000)
|
||||
var card2 = _create_mock_forward(6000)
|
||||
card1.activate()
|
||||
card2.activate()
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "DULL"}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_true(card1.is_dull(), "First card should be dulled")
|
||||
assert_true(card2.is_dull(), "Second card should be dulled")
|
||||
|
||||
|
||||
func test_resolve_dull_already_dull_card() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
card.dull() # Already dull
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "DULL"}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(card.is_dull(), "Card should remain dulled")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ACTIVATE EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_activate_single_target() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
card.dull() # Start dull
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "ACTIVATE"}
|
||||
|
||||
assert_true(card.is_dull(), "Card should start dull")
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(card.is_active(), "Card should be activated")
|
||||
|
||||
|
||||
func test_resolve_activate_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(5000)
|
||||
var card2 = _create_mock_forward(6000)
|
||||
card1.dull()
|
||||
card2.dull()
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "ACTIVATE"}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_true(card1.is_active(), "First card should be active")
|
||||
assert_true(card2.is_active(), "Second card should be active")
|
||||
|
||||
|
||||
func test_resolve_activate_already_active_card() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
card.activate() # Already active
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "ACTIVATE"}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(card.is_active(), "Card should remain active")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRAW EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_draw_single_card() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var game_state = _create_mock_game_state_with_deck(5)
|
||||
var effect = {"type": "DRAW", "amount": 1}
|
||||
|
||||
var initial_deck_size = game_state.players[0].deck.get_count()
|
||||
var initial_hand_size = game_state.players[0].hand.get_count()
|
||||
|
||||
resolver.resolve(effect, source, [], game_state)
|
||||
|
||||
assert_eq(game_state.players[0].deck.get_count(), initial_deck_size - 1, "Deck should have 1 less card")
|
||||
assert_eq(game_state.players[0].hand.get_count(), initial_hand_size + 1, "Hand should have 1 more card")
|
||||
|
||||
|
||||
func test_resolve_draw_multiple_cards() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var game_state = _create_mock_game_state_with_deck(10)
|
||||
var effect = {"type": "DRAW", "amount": 3}
|
||||
|
||||
var initial_deck_size = game_state.players[0].deck.get_count()
|
||||
|
||||
resolver.resolve(effect, source, [], game_state)
|
||||
|
||||
assert_eq(game_state.players[0].deck.get_count(), initial_deck_size - 3, "Deck should have 3 fewer cards")
|
||||
|
||||
|
||||
func test_resolve_draw_opponent() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var game_state = _create_mock_game_state_with_deck(5)
|
||||
# Add cards to opponent's deck too
|
||||
for i in range(5):
|
||||
var deck_card = _create_mock_forward(1000)
|
||||
game_state.players[1].deck.add_card(deck_card)
|
||||
|
||||
var effect = {"type": "DRAW", "amount": 1, "target": {"type": "OPPONENT"}}
|
||||
|
||||
var initial_opp_deck = game_state.players[1].deck.get_count()
|
||||
var initial_opp_hand = game_state.players[1].hand.get_count()
|
||||
|
||||
resolver.resolve(effect, source, [], game_state)
|
||||
|
||||
assert_eq(game_state.players[1].deck.get_count(), initial_opp_deck - 1, "Opponent deck should have 1 less card")
|
||||
assert_eq(game_state.players[1].hand.get_count(), initial_opp_hand + 1, "Opponent hand should have 1 more card")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BREAK EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_break_single_target() -> void:
|
||||
var card = _create_mock_forward(7000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
var effect = {"type": "BREAK"}
|
||||
|
||||
assert_true(game_state.players[0].field_forwards.has_card(card), "Card should start on field")
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_false(game_state.players[0].field_forwards.has_card(card), "Card should not be on field")
|
||||
assert_true(game_state.players[0].break_zone.has_card(card), "Card should be in break zone")
|
||||
|
||||
|
||||
func test_resolve_break_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(5000)
|
||||
var card2 = _create_mock_forward(6000)
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].field_forwards.add_card(card1)
|
||||
game_state.players[0].field_forwards.add_card(card2)
|
||||
card1.controller_index = 0
|
||||
card2.controller_index = 0
|
||||
|
||||
var effect = {"type": "BREAK"}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_true(game_state.players[0].break_zone.has_card(card1), "First card should be in break zone")
|
||||
assert_true(game_state.players[0].break_zone.has_card(card2), "Second card should be in break zone")
|
||||
|
||||
|
||||
func test_resolve_break_backup() -> void:
|
||||
var backup = _create_mock_backup()
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].field_backups.add_card(backup)
|
||||
backup.controller_index = 0
|
||||
|
||||
var effect = {"type": "BREAK"}
|
||||
|
||||
resolver.resolve(effect, null, [backup], game_state)
|
||||
|
||||
assert_true(game_state.players[0].break_zone.has_card(backup), "Backup should be in break zone")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RETURN EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_return_forward_to_hand() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
var effect = {"type": "RETURN"}
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_false(game_state.players[0].field_forwards.has_card(card), "Card should not be on field")
|
||||
assert_true(game_state.players[0].hand.has_card(card), "Card should be in hand")
|
||||
|
||||
|
||||
func test_resolve_return_backup_to_hand() -> void:
|
||||
var backup = _create_mock_backup()
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].field_backups.add_card(backup)
|
||||
backup.controller_index = 0
|
||||
backup.owner_index = 0
|
||||
|
||||
var effect = {"type": "RETURN"}
|
||||
|
||||
resolver.resolve(effect, null, [backup], game_state)
|
||||
|
||||
assert_false(game_state.players[0].field_backups.has_card(backup), "Backup should not be on field")
|
||||
assert_true(game_state.players[0].hand.has_card(backup), "Backup should be in hand")
|
||||
|
||||
|
||||
func test_resolve_return_multiple_targets() -> void:
|
||||
var card1 = _create_mock_forward(5000)
|
||||
var card2 = _create_mock_forward(6000)
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].field_forwards.add_card(card1)
|
||||
game_state.players[0].field_forwards.add_card(card2)
|
||||
card1.controller_index = 0
|
||||
card1.owner_index = 0
|
||||
card2.controller_index = 0
|
||||
card2.owner_index = 0
|
||||
|
||||
var effect = {"type": "RETURN"}
|
||||
|
||||
resolver.resolve(effect, null, [card1, card2], game_state)
|
||||
|
||||
assert_true(game_state.players[0].hand.has_card(card1), "First card should be in hand")
|
||||
assert_true(game_state.players[0].hand.has_card(card2), "Second card should be in hand")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCALING_EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_scaling_effect_by_forwards() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(10000)
|
||||
target.controller_index = 1
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
# Add 3 forwards to controller's field
|
||||
for i in range(3):
|
||||
var forward = _create_mock_forward(4000)
|
||||
forward.controller_index = 0
|
||||
game_state.players[0].field_forwards.add_card(forward)
|
||||
# Add target to opponent's field
|
||||
game_state.players[1].field_forwards.add_card(target)
|
||||
|
||||
var effect = {
|
||||
"type": "SCALING_EFFECT",
|
||||
"base_effect": {"type": "DAMAGE"},
|
||||
"scale_by": "FORWARDS_CONTROLLED",
|
||||
"multiplier": 1000,
|
||||
"scale_filter": {}
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 3000, "Should deal 3000 damage (3 forwards * 1000)")
|
||||
|
||||
|
||||
func test_resolve_scaling_effect_by_backups() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(10000)
|
||||
target.controller_index = 1
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
# Add 4 backups to controller's field
|
||||
for i in range(4):
|
||||
var backup = _create_mock_backup()
|
||||
backup.controller_index = 0
|
||||
game_state.players[0].field_backups.add_card(backup)
|
||||
game_state.players[1].field_forwards.add_card(target)
|
||||
|
||||
var effect = {
|
||||
"type": "SCALING_EFFECT",
|
||||
"base_effect": {"type": "DAMAGE"},
|
||||
"scale_by": "BACKUPS_CONTROLLED",
|
||||
"multiplier": 500,
|
||||
"scale_filter": {}
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 2000, "Should deal 2000 damage (4 backups * 500)")
|
||||
|
||||
|
||||
func test_resolve_scaling_effect_with_filter() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(10000)
|
||||
target.controller_index = 1
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
# Add 2 Fire forwards and 2 Ice forwards
|
||||
for i in range(2):
|
||||
var fire_forward = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
fire_forward.controller_index = 0
|
||||
game_state.players[0].field_forwards.add_card(fire_forward)
|
||||
for i in range(2):
|
||||
var ice_forward = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
ice_forward.controller_index = 0
|
||||
game_state.players[0].field_forwards.add_card(ice_forward)
|
||||
game_state.players[1].field_forwards.add_card(target)
|
||||
|
||||
var effect = {
|
||||
"type": "SCALING_EFFECT",
|
||||
"base_effect": {"type": "DAMAGE"},
|
||||
"scale_by": "FORWARDS_CONTROLLED",
|
||||
"multiplier": 2000,
|
||||
"scale_filter": {"element": "FIRE"} # Only count Fire forwards
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 4000, "Should deal 4000 damage (2 Fire forwards * 2000)")
|
||||
|
||||
|
||||
func test_resolve_scaling_effect_zero_count() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(10000)
|
||||
target.controller_index = 1
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
# No forwards on controller's field
|
||||
game_state.players[1].field_forwards.add_card(target)
|
||||
|
||||
var effect = {
|
||||
"type": "SCALING_EFFECT",
|
||||
"base_effect": {"type": "DAMAGE"},
|
||||
"scale_by": "FORWARDS_CONTROLLED",
|
||||
"multiplier": 1000,
|
||||
"scale_filter": {}
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 0, "Should deal 0 damage (0 forwards * 1000)")
|
||||
|
||||
|
||||
func test_resolve_scaling_effect_power_mod() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
# Add 2 backups
|
||||
for i in range(2):
|
||||
var backup = _create_mock_backup()
|
||||
backup.controller_index = 0
|
||||
game_state.players[0].field_backups.add_card(backup)
|
||||
|
||||
var effect = {
|
||||
"type": "SCALING_EFFECT",
|
||||
"base_effect": {"type": "POWER_MOD"},
|
||||
"scale_by": "BACKUPS_CONTROLLED",
|
||||
"multiplier": 1000,
|
||||
"scale_filter": {}
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [source], game_state)
|
||||
|
||||
assert_eq(source.power_modifiers[0], 2000, "Should get +2000 power (2 backups * 1000)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONDITIONAL EFFECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_resolve_conditional_then_branch() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(7000)
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].damage = 5 # 5 damage points
|
||||
|
||||
# Setup ConditionChecker mock
|
||||
resolver.condition_checker = MockConditionChecker.new(true)
|
||||
|
||||
var effect = {
|
||||
"type": "CONDITIONAL",
|
||||
"condition": {"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 3},
|
||||
"then_effects": [{"type": "DAMAGE", "amount": 5000}],
|
||||
"else_effects": []
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 5000, "Should execute then_effects branch")
|
||||
|
||||
|
||||
func test_resolve_conditional_else_branch() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(7000)
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
game_state.players[0].damage = 1 # Only 1 damage
|
||||
|
||||
# Setup ConditionChecker mock that returns false
|
||||
resolver.condition_checker = MockConditionChecker.new(false)
|
||||
|
||||
var effect = {
|
||||
"type": "CONDITIONAL",
|
||||
"condition": {"type": "DAMAGE_RECEIVED", "comparison": "GTE", "value": 5},
|
||||
"then_effects": [{"type": "DAMAGE", "amount": 5000}],
|
||||
"else_effects": [{"type": "DAMAGE", "amount": 1000}]
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 1000, "Should execute else_effects branch")
|
||||
|
||||
|
||||
func test_resolve_conditional_no_else_branch() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(7000)
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
|
||||
# Setup ConditionChecker mock that returns false
|
||||
resolver.condition_checker = MockConditionChecker.new(false)
|
||||
|
||||
var effect = {
|
||||
"type": "CONDITIONAL",
|
||||
"condition": {"type": "SOME_CONDITION"},
|
||||
"then_effects": [{"type": "DAMAGE", "amount": 5000}],
|
||||
"else_effects": [] # No else branch
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 0, "Should do nothing when condition false and no else")
|
||||
|
||||
|
||||
func test_resolve_conditional_multiple_then_effects() -> void:
|
||||
var source = _create_mock_forward(5000)
|
||||
source.controller_index = 0
|
||||
var target = _create_mock_forward(7000)
|
||||
|
||||
var game_state = _create_mock_game_state()
|
||||
|
||||
resolver.condition_checker = MockConditionChecker.new(true)
|
||||
|
||||
var effect = {
|
||||
"type": "CONDITIONAL",
|
||||
"condition": {"type": "ALWAYS_TRUE"},
|
||||
"then_effects": [
|
||||
{"type": "DAMAGE", "amount": 2000},
|
||||
{"type": "POWER_MOD", "amount": -1000}
|
||||
],
|
||||
"else_effects": []
|
||||
}
|
||||
|
||||
resolver.resolve(effect, source, [target], game_state)
|
||||
|
||||
assert_eq(target.damage_received, 2000, "Should apply damage")
|
||||
assert_eq(target.power_modifiers[0], -1000, "Should apply power mod")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EFFECT_COMPLETED SIGNAL TEST
|
||||
# =============================================================================
|
||||
|
||||
func test_effect_completed_signal_emitted() -> void:
|
||||
var card = _create_mock_forward(5000)
|
||||
var game_state = _create_mock_game_state()
|
||||
var effect = {"type": "DULL"}
|
||||
var signal_received = false
|
||||
var received_effect = {}
|
||||
var received_targets = []
|
||||
|
||||
resolver.effect_completed.connect(func(e, t):
|
||||
signal_received = true
|
||||
received_effect = e
|
||||
received_targets = t
|
||||
)
|
||||
|
||||
resolver.resolve(effect, null, [card], game_state)
|
||||
|
||||
assert_true(signal_received, "effect_completed signal should be emitted")
|
||||
assert_eq(received_effect.type, "DULL", "Signal should contain effect")
|
||||
assert_eq(received_targets.size(), 1, "Signal should contain targets")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMBINED DAMAGE AND BREAK TEST
|
||||
# =============================================================================
|
||||
|
||||
func test_damage_accumulates_before_break() -> void:
|
||||
var card = _create_mock_forward(10000)
|
||||
var game_state = _create_mock_game_state_with_forward(card, 0)
|
||||
|
||||
# Apply 4000 damage (shouldn't break)
|
||||
resolver.resolve({"type": "DAMAGE", "amount": 4000}, null, [card], game_state)
|
||||
assert_eq(card.damage_received, 4000, "Should have 4000 damage")
|
||||
assert_true(game_state.players[0].field_forwards.has_card(card), "Should still be on field")
|
||||
|
||||
# Apply 6000 more (total 10000, should break)
|
||||
resolver.resolve({"type": "DAMAGE", "amount": 6000}, null, [card], game_state)
|
||||
assert_true(game_state.players[0].break_zone.has_card(card), "Should be broken with 10000 total damage")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_forward(power: int) -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.FORWARD
|
||||
data.power = power
|
||||
data.cost = 3
|
||||
data.name = "Test Forward"
|
||||
data.elements = [Enums.Element.FIRE]
|
||||
card.card_data = data
|
||||
card.current_power = power
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_backup() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.BACKUP
|
||||
data.cost = 2
|
||||
data.name = "Test Backup"
|
||||
data.elements = [Enums.Element.WATER]
|
||||
card.card_data = data
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_element(element: Enums.Element) -> CardInstance:
|
||||
var card = _create_mock_forward(5000)
|
||||
card.card_data.elements = [element]
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_game_state() -> MockGameState:
|
||||
return MockGameState.new()
|
||||
|
||||
|
||||
func _create_mock_game_state_with_forward(card: CardInstance, player_index: int) -> MockGameState:
|
||||
var gs = MockGameState.new()
|
||||
gs.players[player_index].field_forwards.add_card(card)
|
||||
card.controller_index = player_index
|
||||
card.owner_index = player_index
|
||||
return gs
|
||||
|
||||
|
||||
func _create_mock_game_state_with_deck(card_count: int) -> MockGameState:
|
||||
var gs = MockGameState.new()
|
||||
for i in range(card_count):
|
||||
var deck_card = _create_mock_forward(1000 + i * 1000)
|
||||
gs.players[0].deck.add_card(deck_card)
|
||||
return gs
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MOCK CLASSES
|
||||
# =============================================================================
|
||||
|
||||
class MockGameState:
|
||||
var players: Array
|
||||
|
||||
func _init():
|
||||
players = [MockPlayer.new(0), MockPlayer.new(1)]
|
||||
|
||||
func get_player(index: int):
|
||||
if index >= 0 and index < players.size():
|
||||
return players[index]
|
||||
return null
|
||||
|
||||
|
||||
class MockPlayer:
|
||||
var player_index: int
|
||||
var damage: int = 0
|
||||
var deck: Zone
|
||||
var hand: Zone
|
||||
var field_forwards: Zone
|
||||
var field_backups: Zone
|
||||
var break_zone: Zone
|
||||
|
||||
func _init(index: int = 0):
|
||||
player_index = index
|
||||
deck = Zone.new(Enums.ZoneType.DECK, index)
|
||||
hand = Zone.new(Enums.ZoneType.HAND, index)
|
||||
field_forwards = Zone.new(Enums.ZoneType.FIELD, index)
|
||||
field_backups = Zone.new(Enums.ZoneType.FIELD, index)
|
||||
break_zone = Zone.new(Enums.ZoneType.BREAK_ZONE, index)
|
||||
|
||||
func draw_cards(count: int) -> Array:
|
||||
var drawn: Array = []
|
||||
for i in range(count):
|
||||
var card = deck.pop_top_card()
|
||||
if card:
|
||||
hand.add_card(card)
|
||||
drawn.append(card)
|
||||
return drawn
|
||||
|
||||
func break_card(card: CardInstance) -> bool:
|
||||
var removed = false
|
||||
|
||||
if field_forwards.has_card(card):
|
||||
field_forwards.remove_card(card)
|
||||
removed = true
|
||||
elif field_backups.has_card(card):
|
||||
field_backups.remove_card(card)
|
||||
removed = true
|
||||
|
||||
if removed:
|
||||
break_zone.add_card(card)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
class MockConditionChecker:
|
||||
var _return_value: bool
|
||||
|
||||
func _init(return_value: bool = true):
|
||||
_return_value = return_value
|
||||
|
||||
func evaluate(_condition: Dictionary, _context: Dictionary) -> bool:
|
||||
return _return_value
|
||||
306
tests/unit/test_filtered_scaling.gd
Normal file
306
tests/unit/test_filtered_scaling.gd
Normal file
@@ -0,0 +1,306 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for filtered scaling in EffectResolver
|
||||
## Tests element, job, category, cost, and card name filters
|
||||
|
||||
# =============================================================================
|
||||
# SETUP
|
||||
# =============================================================================
|
||||
|
||||
var resolver: EffectResolver
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
resolver = EffectResolver.new()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILTER MATCHING TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_element_filter_matches_fire() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var card = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Fire card should match FIRE filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_element_filter_rejects_wrong_element() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
var card = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Ice card should not match FIRE filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_job_filter_matches_samurai() -> void:
|
||||
var filter = {"job": "Samurai"}
|
||||
var card = _create_mock_forward_with_job("Samurai")
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Samurai should match Samurai filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_job_filter_rejects_wrong_job() -> void:
|
||||
var filter = {"job": "Samurai"}
|
||||
var card = _create_mock_forward_with_job("Warrior")
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Warrior should not match Samurai filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_job_filter_case_insensitive() -> void:
|
||||
var filter = {"job": "SAMURAI"}
|
||||
var card = _create_mock_forward_with_job("samurai")
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Job filter should be case insensitive")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_category_filter_matches() -> void:
|
||||
var filter = {"category": "VII"}
|
||||
var card = _create_mock_forward_with_category("VII")
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "VII card should match VII filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_category_filter_rejects_wrong_category() -> void:
|
||||
var filter = {"category": "VII"}
|
||||
var card = _create_mock_forward_with_category("X")
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Category X should not match VII filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_exact_filter_matches() -> void:
|
||||
var filter = {"cost": 3}
|
||||
var card = _create_mock_forward_with_cost(3)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Cost 3 should match cost 3 filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_exact_filter_rejects_wrong_cost() -> void:
|
||||
var filter = {"cost": 3}
|
||||
var card = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Cost 5 should not match cost 3 filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_lte_filter_matches_lower() -> void:
|
||||
var filter = {"cost_comparison": "LTE", "cost_value": 3}
|
||||
var card = _create_mock_forward_with_cost(2)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Cost 2 should match cost <= 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_lte_filter_matches_equal() -> void:
|
||||
var filter = {"cost_comparison": "LTE", "cost_value": 3}
|
||||
var card = _create_mock_forward_with_cost(3)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Cost 3 should match cost <= 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_lte_filter_rejects_higher() -> void:
|
||||
var filter = {"cost_comparison": "LTE", "cost_value": 3}
|
||||
var card = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Cost 5 should not match cost <= 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_gte_filter_matches_higher() -> void:
|
||||
var filter = {"cost_comparison": "GTE", "cost_value": 3}
|
||||
var card = _create_mock_forward_with_cost(5)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Cost 5 should match cost >= 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_cost_gte_filter_rejects_lower() -> void:
|
||||
var filter = {"cost_comparison": "GTE", "cost_value": 3}
|
||||
var card = _create_mock_forward_with_cost(2)
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Cost 2 should not match cost >= 3")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_name_filter_matches() -> void:
|
||||
var filter = {"card_name": "Cloud"}
|
||||
var card = _create_mock_forward_with_name("Cloud")
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Cloud should match Cloud filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_name_filter_rejects_wrong_name() -> void:
|
||||
var filter = {"card_name": "Cloud"}
|
||||
var card = _create_mock_forward_with_name("Tifa")
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Tifa should not match Cloud filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_type_filter_forward() -> void:
|
||||
var filter = {"card_type": "FORWARD"}
|
||||
var card = _create_mock_forward()
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Forward should match FORWARD filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_type_filter_backup() -> void:
|
||||
var filter = {"card_type": "BACKUP"}
|
||||
var card = _create_mock_backup()
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Backup should match BACKUP filter")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_card_type_filter_rejects_wrong_type() -> void:
|
||||
var filter = {"card_type": "BACKUP"}
|
||||
var card = _create_mock_forward()
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(card, filter), "Forward should not match BACKUP filter")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMBINED FILTER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_combined_element_and_type_filter() -> void:
|
||||
var filter = {"element": "FIRE", "card_type": "FORWARD"}
|
||||
|
||||
var fire_forward = _create_mock_forward_with_element(Enums.Element.FIRE)
|
||||
var fire_backup = _create_mock_backup_with_element(Enums.Element.FIRE)
|
||||
var ice_forward = _create_mock_forward_with_element(Enums.Element.ICE)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(fire_forward, filter), "Fire Forward should match")
|
||||
assert_false(resolver._card_matches_scale_filter(fire_backup, filter), "Fire Backup should not match")
|
||||
assert_false(resolver._card_matches_scale_filter(ice_forward, filter), "Ice Forward should not match")
|
||||
|
||||
fire_forward.free()
|
||||
fire_backup.free()
|
||||
ice_forward.free()
|
||||
|
||||
|
||||
func test_combined_job_and_cost_filter() -> void:
|
||||
var filter = {"job": "Warrior", "cost_comparison": "LTE", "cost_value": 3}
|
||||
|
||||
var cheap_warrior = _create_mock_forward_with_job_and_cost("Warrior", 2)
|
||||
var expensive_warrior = _create_mock_forward_with_job_and_cost("Warrior", 5)
|
||||
var cheap_mage = _create_mock_forward_with_job_and_cost("Mage", 2)
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(cheap_warrior, filter), "Cheap Warrior should match")
|
||||
assert_false(resolver._card_matches_scale_filter(expensive_warrior, filter), "Expensive Warrior should not match")
|
||||
assert_false(resolver._card_matches_scale_filter(cheap_mage, filter), "Cheap Mage should not match")
|
||||
|
||||
cheap_warrior.free()
|
||||
expensive_warrior.free()
|
||||
cheap_mage.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMPTY/NULL FILTER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_empty_filter_matches_all() -> void:
|
||||
var filter = {}
|
||||
var card = _create_mock_forward()
|
||||
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Empty filter should match any card")
|
||||
card.free()
|
||||
|
||||
|
||||
func test_null_card_returns_false() -> void:
|
||||
var filter = {"element": "FIRE"}
|
||||
|
||||
assert_false(resolver._card_matches_scale_filter(null, filter), "Null card should return false")
|
||||
|
||||
|
||||
func test_owner_only_filter_treated_as_empty() -> void:
|
||||
# Owner is used for collection selection, not card matching
|
||||
var filter = {"owner": "CONTROLLER"}
|
||||
var card = _create_mock_forward()
|
||||
|
||||
# The filter with only owner should effectively be empty for card matching
|
||||
assert_true(resolver._card_matches_scale_filter(card, filter), "Owner-only filter should match any card")
|
||||
card.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_forward() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.FORWARD
|
||||
data.cost = 3
|
||||
data.power = 7000
|
||||
data.name = "Test Forward"
|
||||
data.job = "Warrior"
|
||||
data.elements = [Enums.Element.FIRE]
|
||||
card.card_data = data
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_backup() -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.type = Enums.CardType.BACKUP
|
||||
data.cost = 2
|
||||
data.name = "Test Backup"
|
||||
data.job = "Scholar"
|
||||
data.elements = [Enums.Element.WATER]
|
||||
card.card_data = data
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_element(element: Enums.Element) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.elements = [element]
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_backup_with_element(element: Enums.Element) -> CardInstance:
|
||||
var card = _create_mock_backup()
|
||||
card.card_data.elements = [element]
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_job(job: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.job = job
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_category(category: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.category = category
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_cost(cost: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.cost = cost
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_name(card_name: String) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.name = card_name
|
||||
return card
|
||||
|
||||
|
||||
func _create_mock_forward_with_job_and_cost(job: String, cost: int) -> CardInstance:
|
||||
var card = _create_mock_forward()
|
||||
card.card_data.job = job
|
||||
card.card_data.cost = cost
|
||||
return card
|
||||
363
tests/unit/test_modal_choice_system.gd
Normal file
363
tests/unit/test_modal_choice_system.gd
Normal file
@@ -0,0 +1,363 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for the Modal Choice System (CHOOSE_MODE effects)
|
||||
## Tests parser output, ChoiceModal UI, and AbilitySystem integration
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PARSER OUTPUT TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_modal_effect_has_correct_structure() -> void:
|
||||
# Simulate a parsed CHOOSE_MODE effect
|
||||
var effect = {
|
||||
"type": "CHOOSE_MODE",
|
||||
"select_count": 1,
|
||||
"select_up_to": false,
|
||||
"mode_count": 3,
|
||||
"modes": [
|
||||
{
|
||||
"index": 0,
|
||||
"description": "Choose 1 Forward. Deal it 7000 damage.",
|
||||
"effects": [{"type": "DAMAGE", "amount": 7000}]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"description": "Choose 1 Monster. Break it.",
|
||||
"effects": [{"type": "BREAK"}]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"description": "Deal 3000 damage to all Forwards.",
|
||||
"effects": [{"type": "DAMAGE", "amount": 3000}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert_eq(effect.type, "CHOOSE_MODE", "Effect type should be CHOOSE_MODE")
|
||||
assert_eq(effect.select_count, 1, "Should select 1 mode")
|
||||
assert_eq(effect.mode_count, 3, "Should have 3 modes")
|
||||
assert_eq(effect.modes.size(), 3, "Modes array should have 3 entries")
|
||||
assert_false(effect.select_up_to, "Should not be 'up to' selection")
|
||||
|
||||
|
||||
func test_modal_effect_with_enhanced_condition() -> void:
|
||||
var effect = {
|
||||
"type": "CHOOSE_MODE",
|
||||
"select_count": 1,
|
||||
"select_up_to": false,
|
||||
"mode_count": 3,
|
||||
"modes": [],
|
||||
"enhanced_condition": {
|
||||
"description": "if you have 5 or more ifrit in your break zone",
|
||||
"select_count": 3,
|
||||
"select_up_to": true
|
||||
}
|
||||
}
|
||||
|
||||
assert_true(effect.has("enhanced_condition"), "Should have enhanced condition")
|
||||
assert_eq(effect.enhanced_condition.select_count, 3, "Enhanced should allow 3 selections")
|
||||
assert_true(effect.enhanced_condition.select_up_to, "Enhanced should be 'up to'")
|
||||
|
||||
|
||||
func test_modal_mode_has_description_and_effects() -> void:
|
||||
var mode = {
|
||||
"index": 0,
|
||||
"description": "Draw 2 cards.",
|
||||
"effects": [{"type": "DRAW", "amount": 2}]
|
||||
}
|
||||
|
||||
assert_true(mode.has("description"), "Mode should have description")
|
||||
assert_true(mode.has("effects"), "Mode should have effects array")
|
||||
assert_eq(mode.effects.size(), 1, "Mode should have 1 effect")
|
||||
assert_eq(mode.effects[0].type, "DRAW", "Effect should be DRAW")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHOICE MODAL TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_choice_modal_instantiates() -> void:
|
||||
var modal = ChoiceModal.new()
|
||||
assert_not_null(modal, "ChoiceModal should instantiate")
|
||||
modal.free()
|
||||
|
||||
|
||||
func test_choice_modal_starts_invisible() -> void:
|
||||
var modal = ChoiceModal.new()
|
||||
add_child_autofree(modal)
|
||||
|
||||
# Need to wait for _ready to complete
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_false(modal.visible, "ChoiceModal should start invisible")
|
||||
|
||||
|
||||
func test_choice_modal_has_correct_layer() -> void:
|
||||
var modal = ChoiceModal.new()
|
||||
add_child_autofree(modal)
|
||||
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_eq(modal.layer, 200, "ChoiceModal should be at layer 200")
|
||||
|
||||
|
||||
func test_choice_modal_signals_exist() -> void:
|
||||
var modal = ChoiceModal.new()
|
||||
|
||||
assert_true(modal.has_signal("choice_made"), "Should have choice_made signal")
|
||||
assert_true(modal.has_signal("choice_cancelled"), "Should have choice_cancelled signal")
|
||||
|
||||
modal.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EFFECT RESOLVER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_effect_resolver_has_choice_required_signal() -> void:
|
||||
var resolver = EffectResolver.new()
|
||||
|
||||
assert_true(resolver.has_signal("choice_required"), "Should have choice_required signal")
|
||||
|
||||
|
||||
func test_effect_resolver_emits_choice_required_for_choose_mode() -> void:
|
||||
var resolver = EffectResolver.new()
|
||||
var signal_received = false
|
||||
var received_effect = {}
|
||||
var received_modes = []
|
||||
|
||||
resolver.choice_required.connect(func(effect, modes):
|
||||
signal_received = true
|
||||
received_effect = effect
|
||||
received_modes = modes
|
||||
)
|
||||
|
||||
var effect = {
|
||||
"type": "CHOOSE_MODE",
|
||||
"modes": [
|
||||
{"index": 0, "description": "Option 1", "effects": []},
|
||||
{"index": 1, "description": "Option 2", "effects": []}
|
||||
]
|
||||
}
|
||||
|
||||
# Resolve the effect
|
||||
resolver.resolve(effect, null, [], null)
|
||||
|
||||
assert_true(signal_received, "Should emit choice_required signal")
|
||||
assert_eq(received_modes.size(), 2, "Should pass modes to signal")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIELD EFFECT MANAGER TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_field_effect_manager_block_immunity_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
# With no abilities registered, should return false
|
||||
var result = manager.has_block_immunity(null, null, null)
|
||||
assert_false(result, "Should return false with no abilities")
|
||||
|
||||
|
||||
func test_field_effect_manager_attack_restriction_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var result = manager.has_attack_restriction(null, null)
|
||||
assert_false(result, "Should return false with no abilities")
|
||||
|
||||
|
||||
func test_field_effect_manager_block_restriction_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var result = manager.has_block_restriction(null, null)
|
||||
assert_false(result, "Should return false with no abilities")
|
||||
|
||||
|
||||
func test_field_effect_manager_taunt_targets_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var result = manager.get_taunt_targets(0, null)
|
||||
assert_eq(result.size(), 0, "Should return empty array with no abilities")
|
||||
|
||||
|
||||
func test_field_effect_manager_cost_modifier_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var result = manager.get_cost_modifier(null, 0, null)
|
||||
assert_eq(result, 0, "Should return 0 with no abilities")
|
||||
|
||||
|
||||
func test_field_effect_manager_max_attacks_default() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var result = manager.get_max_attacks(null, null)
|
||||
assert_eq(result, 1, "Default max attacks should be 1")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BLOCKER IMMUNITY CONDITION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_blocker_immunity_condition_empty() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
# Empty condition means unconditional immunity
|
||||
var result = manager._blocker_matches_immunity_condition(null, {}, null)
|
||||
assert_true(result, "Empty condition should return true (unconditional)")
|
||||
|
||||
|
||||
func test_blocker_immunity_condition_cost_gte() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
# Create mock card data
|
||||
var blocker = _create_mock_card(3, 5000) # cost 3, power 5000
|
||||
|
||||
var condition = {
|
||||
"comparison": "GTE",
|
||||
"attribute": "cost",
|
||||
"value": 4
|
||||
}
|
||||
|
||||
var result = manager._blocker_matches_immunity_condition(blocker, condition, null)
|
||||
assert_false(result, "Cost 3 is not >= 4")
|
||||
|
||||
blocker.card_data.cost = 4
|
||||
result = manager._blocker_matches_immunity_condition(blocker, condition, null)
|
||||
assert_true(result, "Cost 4 is >= 4")
|
||||
|
||||
blocker.free()
|
||||
|
||||
|
||||
func test_blocker_immunity_condition_power_lt_self() -> void:
|
||||
var manager = FieldEffectManager.new()
|
||||
|
||||
var blocker = _create_mock_card(3, 5000)
|
||||
var attacker = _create_mock_card(4, 8000)
|
||||
|
||||
var condition = {
|
||||
"comparison": "LT",
|
||||
"attribute": "power",
|
||||
"compare_to": "SELF_POWER"
|
||||
}
|
||||
|
||||
# Blocker power 5000 < attacker power 8000
|
||||
var result = manager._blocker_matches_immunity_condition(blocker, condition, attacker)
|
||||
assert_true(result, "5000 < 8000 should be true")
|
||||
|
||||
# Change blocker power to be higher
|
||||
blocker.current_power = 9000
|
||||
result = manager._blocker_matches_immunity_condition(blocker, condition, attacker)
|
||||
assert_false(result, "9000 < 8000 should be false")
|
||||
|
||||
blocker.free()
|
||||
attacker.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ABILITY SYSTEM INTEGRATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_ability_system_has_choice_modal_reference() -> void:
|
||||
# Check that AbilitySystem has the choice_modal variable
|
||||
var ability_system_script = load("res://scripts/game/abilities/AbilitySystem.gd")
|
||||
assert_not_null(ability_system_script, "AbilitySystem script should exist")
|
||||
|
||||
|
||||
func test_modes_selected_queues_effects() -> void:
|
||||
# This tests the logic of _on_modes_selected indirectly
|
||||
var effect = {
|
||||
"type": "CHOOSE_MODE",
|
||||
"modes": [
|
||||
{"index": 0, "description": "Deal damage", "effects": [{"type": "DAMAGE", "amount": 5000}]},
|
||||
{"index": 1, "description": "Draw cards", "effects": [{"type": "DRAW", "amount": 2}]}
|
||||
]
|
||||
}
|
||||
|
||||
var modes = effect.modes
|
||||
var selected_indices = [1] # Player selected option 2 (draw)
|
||||
|
||||
# Verify mode selection logic
|
||||
var queued_effects = []
|
||||
for index in selected_indices:
|
||||
if index >= 0 and index < modes.size():
|
||||
var mode = modes[index]
|
||||
for mode_effect in mode.get("effects", []):
|
||||
queued_effects.append(mode_effect)
|
||||
|
||||
assert_eq(queued_effects.size(), 1, "Should queue 1 effect")
|
||||
assert_eq(queued_effects[0].type, "DRAW", "Queued effect should be DRAW")
|
||||
assert_eq(queued_effects[0].amount, 2, "Draw amount should be 2")
|
||||
|
||||
|
||||
func test_multi_select_queues_multiple_effects() -> void:
|
||||
var effect = {
|
||||
"type": "CHOOSE_MODE",
|
||||
"select_count": 2,
|
||||
"modes": [
|
||||
{"index": 0, "effects": [{"type": "DAMAGE", "amount": 5000}]},
|
||||
{"index": 1, "effects": [{"type": "DRAW", "amount": 2}]},
|
||||
{"index": 2, "effects": [{"type": "BREAK"}]}
|
||||
]
|
||||
}
|
||||
|
||||
var modes = effect.modes
|
||||
var selected_indices = [0, 2] # Player selected damage and break
|
||||
|
||||
var queued_effects = []
|
||||
for index in selected_indices:
|
||||
if index >= 0 and index < modes.size():
|
||||
var mode = modes[index]
|
||||
for mode_effect in mode.get("effects", []):
|
||||
queued_effects.append(mode_effect)
|
||||
|
||||
assert_eq(queued_effects.size(), 2, "Should queue 2 effects")
|
||||
assert_eq(queued_effects[0].type, "DAMAGE", "First effect should be DAMAGE")
|
||||
assert_eq(queued_effects[1].type, "BREAK", "Second effect should be BREAK")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENHANCED CONDITION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_enhanced_condition_parsing() -> void:
|
||||
var condition = {
|
||||
"description": "if you have a total of 5 or more card name ifrita and/or card name ifrit in your break zone",
|
||||
"select_count": 3,
|
||||
"select_up_to": true
|
||||
}
|
||||
|
||||
# Test that description contains expected keywords
|
||||
assert_true("break zone" in condition.description, "Should mention break zone")
|
||||
assert_true("5 or more" in condition.description, "Should mention count requirement")
|
||||
|
||||
|
||||
func test_regex_count_extraction() -> void:
|
||||
var description = "if you have 5 or more ifrit in your break zone"
|
||||
|
||||
var regex = RegEx.new()
|
||||
regex.compile(r"(\d+) or more")
|
||||
var match_result = regex.search(description)
|
||||
|
||||
assert_not_null(match_result, "Should find count pattern")
|
||||
assert_eq(match_result.get_string(1), "5", "Should extract '5'")
|
||||
assert_eq(int(match_result.get_string(1)), 5, "Should convert to int 5")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
func _create_mock_card(cost: int, power: int) -> CardInstance:
|
||||
var card = CardInstance.new()
|
||||
|
||||
# Create minimal card data
|
||||
var data = CardDatabase.CardData.new()
|
||||
data.cost = cost
|
||||
data.power = power
|
||||
data.type = Enums.CardType.FORWARD
|
||||
|
||||
card.card_data = data
|
||||
card.current_power = power
|
||||
|
||||
return card
|
||||
293
tests/unit/test_network_signals.gd
Normal file
293
tests/unit/test_network_signals.gd
Normal file
@@ -0,0 +1,293 @@
|
||||
extends GutTest
|
||||
|
||||
## Unit tests for NetworkManager signal emissions
|
||||
## Tests signal behavior without requiring actual network connections
|
||||
|
||||
|
||||
var network_manager: NetworkManager
|
||||
var signal_received: bool = false
|
||||
var last_signal_data: Variant = null
|
||||
|
||||
|
||||
func before_each():
|
||||
# Create a fresh NetworkManager instance for testing
|
||||
network_manager = NetworkManager.new()
|
||||
add_child_autoqfree(network_manager)
|
||||
signal_received = false
|
||||
last_signal_data = null
|
||||
|
||||
|
||||
func after_each():
|
||||
# Cleanup handled by autoqfree
|
||||
pass
|
||||
|
||||
|
||||
# ======= HELPER FUNCTIONS =======
|
||||
|
||||
func _connect_signal_and_track(signal_obj: Signal) -> void:
|
||||
signal_obj.connect(_on_signal_received)
|
||||
|
||||
|
||||
func _on_signal_received(data = null) -> void:
|
||||
signal_received = true
|
||||
last_signal_data = data
|
||||
|
||||
|
||||
# ======= CONNECTION STATE SIGNAL TESTS =======
|
||||
|
||||
func test_connection_state_changed_signal_exists():
|
||||
assert_has_signal(network_manager, "connection_state_changed")
|
||||
|
||||
|
||||
func test_authenticated_signal_exists():
|
||||
assert_has_signal(network_manager, "authenticated")
|
||||
|
||||
|
||||
func test_authentication_failed_signal_exists():
|
||||
assert_has_signal(network_manager, "authentication_failed")
|
||||
|
||||
|
||||
func test_logged_out_signal_exists():
|
||||
assert_has_signal(network_manager, "logged_out")
|
||||
|
||||
|
||||
func test_network_error_signal_exists():
|
||||
assert_has_signal(network_manager, "network_error")
|
||||
|
||||
|
||||
# ======= MATCHMAKING SIGNAL TESTS =======
|
||||
|
||||
func test_queue_joined_signal_exists():
|
||||
assert_has_signal(network_manager, "queue_joined")
|
||||
|
||||
|
||||
func test_queue_left_signal_exists():
|
||||
assert_has_signal(network_manager, "queue_left")
|
||||
|
||||
|
||||
func test_match_found_signal_exists():
|
||||
assert_has_signal(network_manager, "match_found")
|
||||
|
||||
|
||||
func test_room_created_signal_exists():
|
||||
assert_has_signal(network_manager, "room_created")
|
||||
|
||||
|
||||
func test_room_joined_signal_exists():
|
||||
assert_has_signal(network_manager, "room_joined")
|
||||
|
||||
|
||||
func test_room_updated_signal_exists():
|
||||
assert_has_signal(network_manager, "room_updated")
|
||||
|
||||
|
||||
func test_matchmaking_update_signal_exists():
|
||||
assert_has_signal(network_manager, "matchmaking_update")
|
||||
|
||||
|
||||
# ======= GAME MESSAGE SIGNAL TESTS =======
|
||||
|
||||
func test_opponent_action_received_signal_exists():
|
||||
assert_has_signal(network_manager, "opponent_action_received")
|
||||
|
||||
|
||||
func test_turn_timer_update_signal_exists():
|
||||
assert_has_signal(network_manager, "turn_timer_update")
|
||||
|
||||
|
||||
func test_game_started_signal_exists():
|
||||
assert_has_signal(network_manager, "game_started")
|
||||
|
||||
|
||||
func test_game_ended_signal_exists():
|
||||
assert_has_signal(network_manager, "game_ended")
|
||||
|
||||
|
||||
func test_phase_changed_signal_exists():
|
||||
assert_has_signal(network_manager, "phase_changed")
|
||||
|
||||
|
||||
func test_action_confirmed_signal_exists():
|
||||
assert_has_signal(network_manager, "action_confirmed")
|
||||
|
||||
|
||||
func test_action_failed_signal_exists():
|
||||
assert_has_signal(network_manager, "action_failed")
|
||||
|
||||
|
||||
func test_opponent_disconnected_signal_exists():
|
||||
assert_has_signal(network_manager, "opponent_disconnected")
|
||||
|
||||
|
||||
func test_opponent_reconnected_signal_exists():
|
||||
assert_has_signal(network_manager, "opponent_reconnected")
|
||||
|
||||
|
||||
func test_game_state_sync_signal_exists():
|
||||
assert_has_signal(network_manager, "game_state_sync")
|
||||
|
||||
|
||||
# ======= CONNECTION STATE ENUM TESTS =======
|
||||
|
||||
func test_connection_state_disconnected_value():
|
||||
assert_eq(NetworkManager.ConnectionState.DISCONNECTED, 0)
|
||||
|
||||
|
||||
func test_connection_state_connecting_value():
|
||||
assert_eq(NetworkManager.ConnectionState.CONNECTING, 1)
|
||||
|
||||
|
||||
func test_connection_state_connected_value():
|
||||
assert_eq(NetworkManager.ConnectionState.CONNECTED, 2)
|
||||
|
||||
|
||||
func test_connection_state_authenticating_value():
|
||||
assert_eq(NetworkManager.ConnectionState.AUTHENTICATING, 3)
|
||||
|
||||
|
||||
func test_connection_state_authenticated_value():
|
||||
assert_eq(NetworkManager.ConnectionState.AUTHENTICATED, 4)
|
||||
|
||||
|
||||
func test_connection_state_in_queue_value():
|
||||
assert_eq(NetworkManager.ConnectionState.IN_QUEUE, 5)
|
||||
|
||||
|
||||
func test_connection_state_in_room_value():
|
||||
assert_eq(NetworkManager.ConnectionState.IN_ROOM, 6)
|
||||
|
||||
|
||||
func test_connection_state_in_game_value():
|
||||
assert_eq(NetworkManager.ConnectionState.IN_GAME, 7)
|
||||
|
||||
|
||||
# ======= INITIAL STATE TESTS =======
|
||||
|
||||
func test_initial_connection_state_is_disconnected():
|
||||
assert_eq(network_manager.connection_state, NetworkManager.ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
func test_initial_auth_token_is_empty():
|
||||
assert_eq(network_manager.auth_token, "")
|
||||
|
||||
|
||||
func test_initial_is_authenticated_is_false():
|
||||
assert_false(network_manager.is_authenticated)
|
||||
|
||||
|
||||
func test_initial_current_game_id_is_empty():
|
||||
assert_eq(network_manager.current_game_id, "")
|
||||
|
||||
|
||||
func test_initial_current_room_code_is_empty():
|
||||
assert_eq(network_manager.current_room_code, "")
|
||||
|
||||
|
||||
func test_initial_local_player_index_is_zero():
|
||||
assert_eq(network_manager.local_player_index, 0)
|
||||
|
||||
|
||||
# ======= LOGOUT TESTS =======
|
||||
|
||||
func test_logout_clears_auth_token():
|
||||
network_manager.auth_token = "test_token"
|
||||
network_manager.logout()
|
||||
assert_eq(network_manager.auth_token, "")
|
||||
|
||||
|
||||
func test_logout_clears_current_user():
|
||||
network_manager.current_user = { "id": "123", "username": "test" }
|
||||
network_manager.logout()
|
||||
assert_eq(network_manager.current_user, {})
|
||||
|
||||
|
||||
func test_logout_sets_is_authenticated_false():
|
||||
network_manager.is_authenticated = true
|
||||
network_manager.logout()
|
||||
assert_false(network_manager.is_authenticated)
|
||||
|
||||
|
||||
func test_logout_emits_logged_out_signal():
|
||||
watch_signals(network_manager)
|
||||
network_manager.logout()
|
||||
assert_signal_emitted(network_manager, "logged_out")
|
||||
|
||||
|
||||
# ======= CONNECTION STATE NAME TESTS =======
|
||||
|
||||
func test_get_connection_state_name_disconnected():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.DISCONNECTED
|
||||
assert_eq(network_manager.get_connection_state_name(), "Disconnected")
|
||||
|
||||
|
||||
func test_get_connection_state_name_connecting():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.CONNECTING
|
||||
assert_eq(network_manager.get_connection_state_name(), "Connecting")
|
||||
|
||||
|
||||
func test_get_connection_state_name_connected():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.CONNECTED
|
||||
assert_eq(network_manager.get_connection_state_name(), "Connected")
|
||||
|
||||
|
||||
func test_get_connection_state_name_authenticating():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.AUTHENTICATING
|
||||
assert_eq(network_manager.get_connection_state_name(), "Authenticating")
|
||||
|
||||
|
||||
func test_get_connection_state_name_authenticated():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.AUTHENTICATED
|
||||
assert_eq(network_manager.get_connection_state_name(), "Online")
|
||||
|
||||
|
||||
func test_get_connection_state_name_in_queue():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.IN_QUEUE
|
||||
assert_eq(network_manager.get_connection_state_name(), "In Queue")
|
||||
|
||||
|
||||
func test_get_connection_state_name_in_room():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.IN_ROOM
|
||||
assert_eq(network_manager.get_connection_state_name(), "In Room")
|
||||
|
||||
|
||||
func test_get_connection_state_name_in_game():
|
||||
network_manager.connection_state = NetworkManager.ConnectionState.IN_GAME
|
||||
assert_eq(network_manager.get_connection_state_name(), "In Game")
|
||||
|
||||
|
||||
# ======= CONFIGURATION TESTS =======
|
||||
|
||||
func test_configure_sets_http_url():
|
||||
network_manager.configure("http://test.com", "ws://test.com")
|
||||
assert_eq(network_manager.http_base_url, "http://test.com")
|
||||
|
||||
|
||||
func test_configure_sets_ws_url():
|
||||
network_manager.configure("http://test.com", "ws://test.com:3001")
|
||||
assert_eq(network_manager.ws_url, "ws://test.com:3001")
|
||||
|
||||
|
||||
func test_default_http_url():
|
||||
assert_eq(network_manager.http_base_url, "http://localhost:3000")
|
||||
|
||||
|
||||
func test_default_ws_url():
|
||||
assert_eq(network_manager.ws_url, "ws://localhost:3001")
|
||||
|
||||
|
||||
# ======= CONSTANTS TESTS =======
|
||||
|
||||
func test_heartbeat_interval_constant():
|
||||
assert_eq(NetworkManager.HEARTBEAT_INTERVAL, 10.0)
|
||||
|
||||
|
||||
func test_reconnect_delay_constant():
|
||||
assert_eq(NetworkManager.RECONNECT_DELAY, 5.0)
|
||||
|
||||
|
||||
func test_max_reconnect_attempts_constant():
|
||||
assert_eq(NetworkManager.MAX_RECONNECT_ATTEMPTS, 3)
|
||||
|
||||
|
||||
func test_token_file_constant():
|
||||
assert_eq(NetworkManager.TOKEN_FILE, "user://auth_token.dat")
|
||||
280
tests/unit/test_online_game_helpers.gd
Normal file
280
tests/unit/test_online_game_helpers.gd
Normal file
@@ -0,0 +1,280 @@
|
||||
extends GutTest
|
||||
|
||||
## Unit tests for online game helper functions in Main.gd
|
||||
## Tests the logic for determining if local player can act
|
||||
|
||||
|
||||
# Mock classes for testing
|
||||
class MockTurnManager:
|
||||
var current_player_index: int = 0
|
||||
var turn_number: int = 1
|
||||
var current_phase: int = 0
|
||||
|
||||
|
||||
class MockGameState:
|
||||
var turn_manager: MockTurnManager
|
||||
|
||||
func _init():
|
||||
turn_manager = MockTurnManager.new()
|
||||
|
||||
|
||||
# Simulated Main.gd helper functions for testing
|
||||
# These mirror the actual functions in Main.gd
|
||||
|
||||
var is_online_game: bool = false
|
||||
var local_player_index: int = 0
|
||||
var mock_game_state: MockGameState = null
|
||||
|
||||
|
||||
func before_each():
|
||||
is_online_game = false
|
||||
local_player_index = 0
|
||||
mock_game_state = MockGameState.new()
|
||||
|
||||
|
||||
# Helper function that mirrors Main.gd._is_local_player_turn()
|
||||
func _is_local_player_turn() -> bool:
|
||||
if not is_online_game:
|
||||
return true
|
||||
if not mock_game_state:
|
||||
return true
|
||||
return mock_game_state.turn_manager.current_player_index == local_player_index
|
||||
|
||||
|
||||
# Helper function that mirrors Main.gd._can_perform_local_action()
|
||||
func _can_perform_local_action() -> bool:
|
||||
if not is_online_game:
|
||||
return true
|
||||
return _is_local_player_turn()
|
||||
|
||||
|
||||
# ======= _is_local_player_turn() Tests =======
|
||||
|
||||
func test_is_local_player_turn_returns_true_when_not_online():
|
||||
is_online_game = false
|
||||
mock_game_state.turn_manager.current_player_index = 1
|
||||
local_player_index = 0
|
||||
|
||||
assert_true(_is_local_player_turn(), "Should return true when not in online game")
|
||||
|
||||
|
||||
func test_is_local_player_turn_returns_true_when_no_game_state():
|
||||
is_online_game = true
|
||||
mock_game_state = null
|
||||
|
||||
assert_true(_is_local_player_turn(), "Should return true when game_state is null")
|
||||
|
||||
|
||||
func test_is_local_player_turn_returns_true_when_local_index_matches():
|
||||
is_online_game = true
|
||||
local_player_index = 0
|
||||
mock_game_state.turn_manager.current_player_index = 0
|
||||
|
||||
assert_true(_is_local_player_turn(), "Should return true when it's local player's turn")
|
||||
|
||||
|
||||
func test_is_local_player_turn_returns_false_when_opponent_turn():
|
||||
is_online_game = true
|
||||
local_player_index = 0
|
||||
mock_game_state.turn_manager.current_player_index = 1
|
||||
|
||||
assert_false(_is_local_player_turn(), "Should return false when it's opponent's turn")
|
||||
|
||||
|
||||
func test_is_local_player_turn_player_1_perspective():
|
||||
is_online_game = true
|
||||
local_player_index = 1
|
||||
mock_game_state.turn_manager.current_player_index = 1
|
||||
|
||||
assert_true(_is_local_player_turn(), "Player 1 should be able to act on their turn")
|
||||
|
||||
|
||||
func test_is_local_player_turn_player_1_opponent_turn():
|
||||
is_online_game = true
|
||||
local_player_index = 1
|
||||
mock_game_state.turn_manager.current_player_index = 0
|
||||
|
||||
assert_false(_is_local_player_turn(), "Player 1 should not act on opponent's turn")
|
||||
|
||||
|
||||
# ======= _can_perform_local_action() Tests =======
|
||||
|
||||
func test_can_perform_local_action_delegates_to_is_local_player_turn_online():
|
||||
is_online_game = true
|
||||
local_player_index = 0
|
||||
mock_game_state.turn_manager.current_player_index = 0
|
||||
|
||||
assert_true(_can_perform_local_action())
|
||||
|
||||
mock_game_state.turn_manager.current_player_index = 1
|
||||
assert_false(_can_perform_local_action())
|
||||
|
||||
|
||||
func test_can_perform_local_action_always_true_offline():
|
||||
is_online_game = false
|
||||
local_player_index = 0
|
||||
mock_game_state.turn_manager.current_player_index = 1
|
||||
|
||||
assert_true(_can_perform_local_action(), "Should always be able to act in offline game")
|
||||
|
||||
|
||||
# ======= Player Index Validation Tests =======
|
||||
|
||||
func test_valid_player_index_0():
|
||||
var player_index = 0
|
||||
assert_true(player_index >= 0 and player_index <= 1, "Player index 0 should be valid")
|
||||
|
||||
|
||||
func test_valid_player_index_1():
|
||||
var player_index = 1
|
||||
assert_true(player_index >= 0 and player_index <= 1, "Player index 1 should be valid")
|
||||
|
||||
|
||||
func test_invalid_player_index_negative():
|
||||
var player_index = -1
|
||||
assert_false(player_index >= 0 and player_index <= 1, "Negative player index should be invalid")
|
||||
|
||||
|
||||
func test_invalid_player_index_too_high():
|
||||
var player_index = 2
|
||||
assert_false(player_index >= 0 and player_index <= 1, "Player index > 1 should be invalid")
|
||||
|
||||
|
||||
# ======= Turn Timer Format Tests =======
|
||||
|
||||
func test_timer_format_full_minutes():
|
||||
var seconds = 120
|
||||
var minutes = seconds / 60
|
||||
var secs = seconds % 60
|
||||
var formatted = "%d:%02d" % [minutes, secs]
|
||||
|
||||
assert_eq(formatted, "2:00")
|
||||
|
||||
|
||||
func test_timer_format_partial_minutes():
|
||||
var seconds = 90
|
||||
var minutes = seconds / 60
|
||||
var secs = seconds % 60
|
||||
var formatted = "%d:%02d" % [minutes, secs]
|
||||
|
||||
assert_eq(formatted, "1:30")
|
||||
|
||||
|
||||
func test_timer_format_under_minute():
|
||||
var seconds = 45
|
||||
var minutes = seconds / 60
|
||||
var secs = seconds % 60
|
||||
var formatted = "%d:%02d" % [minutes, secs]
|
||||
|
||||
assert_eq(formatted, "0:45")
|
||||
|
||||
|
||||
func test_timer_format_single_digit_seconds():
|
||||
var seconds = 65
|
||||
var minutes = seconds / 60
|
||||
var secs = seconds % 60
|
||||
var formatted = "%d:%02d" % [minutes, secs]
|
||||
|
||||
assert_eq(formatted, "1:05")
|
||||
|
||||
|
||||
func test_timer_format_zero():
|
||||
var seconds = 0
|
||||
var minutes = seconds / 60
|
||||
var secs = seconds % 60
|
||||
var formatted = "%d:%02d" % [minutes, secs]
|
||||
|
||||
assert_eq(formatted, "0:00")
|
||||
|
||||
|
||||
# ======= Timer Color Thresholds Tests =======
|
||||
|
||||
func test_timer_color_threshold_critical():
|
||||
var seconds = 10
|
||||
var is_critical = seconds <= 10
|
||||
|
||||
assert_true(is_critical, "10 seconds should be critical (red)")
|
||||
|
||||
|
||||
func test_timer_color_threshold_warning():
|
||||
var seconds = 30
|
||||
var is_warning = seconds > 10 and seconds <= 30
|
||||
|
||||
assert_true(is_warning, "30 seconds should be warning (yellow)")
|
||||
|
||||
|
||||
func test_timer_color_threshold_normal():
|
||||
var seconds = 60
|
||||
var is_normal = seconds > 30
|
||||
|
||||
assert_true(is_normal, "60 seconds should be normal (white)")
|
||||
|
||||
|
||||
func test_timer_color_threshold_boundary_10():
|
||||
var seconds = 10
|
||||
var is_critical = seconds <= 10
|
||||
|
||||
assert_true(is_critical, "Exactly 10 should be critical")
|
||||
|
||||
|
||||
func test_timer_color_threshold_boundary_11():
|
||||
var seconds = 11
|
||||
var is_warning = seconds > 10 and seconds <= 30
|
||||
|
||||
assert_true(is_warning, "11 should be warning, not critical")
|
||||
|
||||
|
||||
func test_timer_color_threshold_boundary_30():
|
||||
var seconds = 30
|
||||
var is_warning = seconds > 10 and seconds <= 30
|
||||
|
||||
assert_true(is_warning, "Exactly 30 should be warning")
|
||||
|
||||
|
||||
func test_timer_color_threshold_boundary_31():
|
||||
var seconds = 31
|
||||
var is_normal = seconds > 30
|
||||
|
||||
assert_true(is_normal, "31 should be normal, not warning")
|
||||
|
||||
|
||||
# ======= Online Game State Sync Tests =======
|
||||
|
||||
func test_game_state_sync_updates_current_player():
|
||||
var state = { "current_player_index": 1, "current_phase": 2, "turn_number": 3 }
|
||||
|
||||
mock_game_state.turn_manager.current_player_index = state.get("current_player_index", 0)
|
||||
|
||||
assert_eq(mock_game_state.turn_manager.current_player_index, 1)
|
||||
|
||||
|
||||
func test_game_state_sync_updates_phase():
|
||||
var state = { "current_player_index": 0, "current_phase": 3, "turn_number": 1 }
|
||||
|
||||
mock_game_state.turn_manager.current_phase = state.get("current_phase", 0)
|
||||
|
||||
assert_eq(mock_game_state.turn_manager.current_phase, 3)
|
||||
|
||||
|
||||
func test_game_state_sync_updates_turn_number():
|
||||
var state = { "current_player_index": 0, "current_phase": 0, "turn_number": 5 }
|
||||
|
||||
mock_game_state.turn_manager.turn_number = state.get("turn_number", 1)
|
||||
|
||||
assert_eq(mock_game_state.turn_manager.turn_number, 5)
|
||||
|
||||
|
||||
func test_game_state_sync_timer_extraction():
|
||||
var state = { "turn_timer_seconds": 90 }
|
||||
|
||||
var timer_seconds = state.get("turn_timer_seconds", 120)
|
||||
|
||||
assert_eq(timer_seconds, 90)
|
||||
|
||||
|
||||
func test_game_state_sync_timer_default():
|
||||
var state = {}
|
||||
|
||||
var timer_seconds = state.get("turn_timer_seconds", 120)
|
||||
|
||||
assert_eq(timer_seconds, 120, "Should default to 120 seconds")
|
||||
191
tests/unit/test_optional_effects.gd
Normal file
191
tests/unit/test_optional_effects.gd
Normal file
@@ -0,0 +1,191 @@
|
||||
extends GutTest
|
||||
|
||||
## Tests for optional effect prompting in AbilitySystem
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUILD EFFECT DESCRIPTION TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_build_description_draw_single() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DRAW", "amount": 1}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Draw 1 card", "Should describe single card draw")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_draw_multiple() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DRAW", "amount": 3}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Draw 3 cards", "Should describe multiple card draw with plural")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_damage() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DAMAGE", "amount": 5000}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Deal 5000 damage", "Should describe damage amount")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_power_mod_positive() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "POWER_MOD", "amount": 2000}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Give +2000 power", "Should describe positive power mod with +")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_power_mod_negative() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "POWER_MOD", "amount": -3000}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Give -3000 power", "Should describe negative power mod")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_dull() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DULL"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Dull a Forward", "Should describe dull effect")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_activate() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "ACTIVATE"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Activate a card", "Should describe activate effect")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_break() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "BREAK"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Break a card", "Should describe break effect")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_return() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "RETURN"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Return a card to hand", "Should describe return effect")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_search() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "SEARCH"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Search your deck", "Should describe search effect")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_discard_single() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DISCARD", "amount": 1}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Discard 1 card", "Should describe single discard")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_discard_multiple() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "DISCARD", "amount": 2}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Discard 2 cards", "Should describe multiple discard with plural")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_uses_original_text() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "UNKNOWN_EFFECT", "original_text": "Do something special"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Do something special", "Should use original_text for unknown effects")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
func test_build_description_fallback() -> void:
|
||||
var ability_system = AbilitySystem.new()
|
||||
var effect = {"type": "UNKNOWN_EFFECT"}
|
||||
|
||||
var description = ability_system._build_effect_description(effect)
|
||||
|
||||
assert_eq(description, "Use this effect", "Should use fallback for unknown effects without original_text")
|
||||
ability_system.free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL EFFECT FLAG TESTS
|
||||
# =============================================================================
|
||||
|
||||
func test_optional_flag_is_detected() -> void:
|
||||
var effect_with_optional = {"type": "DRAW", "amount": 1, "optional": true}
|
||||
var effect_without_optional = {"type": "DRAW", "amount": 1}
|
||||
|
||||
assert_true(effect_with_optional.get("optional", false), "Should detect optional flag")
|
||||
assert_false(effect_without_optional.get("optional", false), "Should default to false")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION TESTS (Signal Emission)
|
||||
# =============================================================================
|
||||
|
||||
func test_optional_effect_signal_contains_correct_data() -> void:
|
||||
# This test verifies the signal parameters are correct
|
||||
var ability_system = AbilitySystem.new()
|
||||
var received_player_index = -1
|
||||
var received_effect = {}
|
||||
var received_description = ""
|
||||
var received_callback = null
|
||||
|
||||
ability_system.optional_effect_prompt.connect(func(player_index, effect, description, callback):
|
||||
received_player_index = player_index
|
||||
received_effect = effect
|
||||
received_description = description
|
||||
received_callback = callback
|
||||
)
|
||||
|
||||
# Manually emit to test signal structure
|
||||
var test_effect = {"type": "DRAW", "amount": 2, "optional": true}
|
||||
var test_callback = func(_accepted): pass
|
||||
ability_system.optional_effect_prompt.emit(0, test_effect, "Draw 2 cards", test_callback)
|
||||
|
||||
assert_eq(received_player_index, 0, "Player index should be passed")
|
||||
assert_eq(received_effect.type, "DRAW", "Effect should be passed")
|
||||
assert_eq(received_description, "Draw 2 cards", "Description should be passed")
|
||||
assert_not_null(received_callback, "Callback should be passed")
|
||||
|
||||
ability_system.free()
|
||||
BIN
tools/__pycache__/ability_processor.cpython-312.pyc
Normal file
BIN
tools/__pycache__/ability_processor.cpython-312.pyc
Normal file
Binary file not shown.
4628
tools/ability_processor.py
Normal file
4628
tools/ability_processor.py
Normal file
File diff suppressed because it is too large
Load Diff
372
tools/ability_validator.py
Normal file
372
tools/ability_validator.py
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ability Validator - Reviews processed abilities and identifies issues for correction.
|
||||
|
||||
This tool helps ensure all abilities are correctly parsed before shipping to players.
|
||||
Run this after ability_processor.py to review and fix any problems.
|
||||
|
||||
Usage:
|
||||
python tools/ability_validator.py # Full validation report
|
||||
python tools/ability_validator.py --unparsed # Show only unparsed abilities
|
||||
python tools/ability_validator.py --low-confidence # Show low confidence parses
|
||||
python tools/ability_validator.py --card 1-001H # Validate specific card
|
||||
python tools/ability_validator.py --effect DAMAGE # Show all DAMAGE effects
|
||||
python tools/ability_validator.py --summary # Quick summary only
|
||||
python tools/ability_validator.py --export issues.json # Export issues for review
|
||||
"""
|
||||
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
ABILITIES_FILE = Path(__file__).parent.parent / "data" / "abilities_processed.json"
|
||||
CARDS_FILE = Path(__file__).parent.parent / "data" / "cards.json"
|
||||
|
||||
|
||||
def load_abilities() -> dict:
|
||||
"""Load processed abilities file."""
|
||||
if not ABILITIES_FILE.exists():
|
||||
print(f"ERROR: {ABILITIES_FILE} not found!")
|
||||
print("Run: python tools/ability_processor.py first")
|
||||
return {}
|
||||
|
||||
with open(ABILITIES_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_cards() -> dict:
|
||||
"""Load cards file for reference."""
|
||||
if not CARDS_FILE.exists():
|
||||
return {}
|
||||
|
||||
with open(CARDS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
cards = data.get('cards', data) if isinstance(data, dict) else data
|
||||
return {card['id']: card for card in cards}
|
||||
|
||||
|
||||
def get_issues(abilities_data: dict) -> dict:
|
||||
"""Analyze abilities and categorize issues."""
|
||||
issues = {
|
||||
'unparsed': [], # Completely failed to parse
|
||||
'low_confidence': [], # Parsed but low confidence
|
||||
'unknown_effects': [], # Has UNKNOWN effect type
|
||||
'missing_targets': [], # Effect needs target but none specified
|
||||
'complex_conditions': [], # Has conditions we can't fully parse
|
||||
}
|
||||
|
||||
abilities = abilities_data.get('abilities', {})
|
||||
|
||||
for card_id, card_abilities in abilities.items():
|
||||
for ability in card_abilities:
|
||||
parsed = ability.get('parsed', {})
|
||||
confidence = ability.get('parse_confidence', 'LOW')
|
||||
original = ability.get('original', {})
|
||||
|
||||
# Check for unparsed
|
||||
if not parsed or parsed.get('type') == 'UNPARSED':
|
||||
issues['unparsed'].append({
|
||||
'card_id': card_id,
|
||||
'ability_index': ability.get('ability_index', 0),
|
||||
'original': original,
|
||||
'reason': 'Failed to parse ability type'
|
||||
})
|
||||
continue
|
||||
|
||||
# Check confidence level
|
||||
if confidence == 'LOW':
|
||||
issues['low_confidence'].append({
|
||||
'card_id': card_id,
|
||||
'ability_index': ability.get('ability_index', 0),
|
||||
'original': original,
|
||||
'parsed': parsed,
|
||||
'reason': 'Low confidence parse - may be incorrect'
|
||||
})
|
||||
|
||||
# Check for UNKNOWN effects
|
||||
for effect in parsed.get('effects', []):
|
||||
if effect.get('type') == 'UNKNOWN':
|
||||
issues['unknown_effects'].append({
|
||||
'card_id': card_id,
|
||||
'ability_index': ability.get('ability_index', 0),
|
||||
'original': original,
|
||||
'effect': effect,
|
||||
'reason': f"Unknown effect: {effect.get('raw', 'no text')[:80]}"
|
||||
})
|
||||
|
||||
# Check for missing targets on effects that need them
|
||||
effect_type = effect.get('type', '')
|
||||
needs_target = effect_type in ['DAMAGE', 'BREAK', 'DULL', 'ACTIVATE', 'RETURN', 'POWER_MOD']
|
||||
has_target = effect.get('target', {})
|
||||
|
||||
if needs_target and not has_target:
|
||||
issues['missing_targets'].append({
|
||||
'card_id': card_id,
|
||||
'ability_index': ability.get('ability_index', 0),
|
||||
'effect_type': effect_type,
|
||||
'original': original,
|
||||
'reason': f'{effect_type} effect missing target specification'
|
||||
})
|
||||
|
||||
# Check for complex conditions
|
||||
trigger = parsed.get('trigger', {})
|
||||
if trigger.get('condition'):
|
||||
issues['complex_conditions'].append({
|
||||
'card_id': card_id,
|
||||
'ability_index': ability.get('ability_index', 0),
|
||||
'original': original,
|
||||
'condition': trigger.get('condition'),
|
||||
'reason': 'Has conditional trigger - verify correctness'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def print_summary(abilities_data: dict, issues: dict) -> None:
|
||||
"""Print a summary of the validation results."""
|
||||
stats = abilities_data.get('statistics', {})
|
||||
|
||||
print("=" * 70)
|
||||
print("ABILITY VALIDATION SUMMARY")
|
||||
print("=" * 70)
|
||||
print(f"Generated: {abilities_data.get('generated_at', 'unknown')}")
|
||||
print(f"Version: {abilities_data.get('version', 'unknown')}")
|
||||
print()
|
||||
print(f"Total Cards: {stats.get('total_cards', 0):,}")
|
||||
print(f"Total Abilities: {stats.get('total_abilities', 0):,}")
|
||||
print()
|
||||
print("Parse Confidence:")
|
||||
print(f" HIGH: {stats.get('parsed_high', 0):,} ({100*stats.get('parsed_high', 0)/max(1, stats.get('total_abilities', 1)):.1f}%)")
|
||||
print(f" MEDIUM: {stats.get('parsed_medium', 0):,} ({100*stats.get('parsed_medium', 0)/max(1, stats.get('total_abilities', 1)):.1f}%)")
|
||||
print(f" LOW: {stats.get('parsed_low', 0):,} ({100*stats.get('parsed_low', 0)/max(1, stats.get('total_abilities', 1)):.1f}%)")
|
||||
print()
|
||||
print("Issues Found:")
|
||||
print(f" Unparsed abilities: {len(issues['unparsed']):,}")
|
||||
print(f" Low confidence: {len(issues['low_confidence']):,}")
|
||||
print(f" Unknown effects: {len(issues['unknown_effects']):,}")
|
||||
print(f" Missing targets: {len(issues['missing_targets']):,}")
|
||||
print(f" Complex conditions: {len(issues['complex_conditions']):,}")
|
||||
print()
|
||||
|
||||
total_issues = sum(len(v) for v in issues.values())
|
||||
if total_issues == 0:
|
||||
print("✓ No issues found! Abilities are ready for shipping.")
|
||||
else:
|
||||
print(f"⚠ {total_issues} total issues to review")
|
||||
print()
|
||||
print("Run with --unparsed, --low-confidence, or --effect to see details")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def print_issues(issues: list, title: str, cards: dict, limit: int = 50) -> None:
|
||||
"""Print a list of issues with details."""
|
||||
if not issues:
|
||||
print(f"\n{title}: None found ✓")
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f"{title} ({len(issues)} total)")
|
||||
print("=" * 70)
|
||||
|
||||
for i, issue in enumerate(issues[:limit]):
|
||||
card_id = issue['card_id']
|
||||
card = cards.get(card_id, {})
|
||||
card_name = card.get('name', 'Unknown')
|
||||
|
||||
print(f"\n[{i+1}] {card_id} - {card_name}")
|
||||
print(f" Reason: {issue.get('reason', 'Unknown')}")
|
||||
|
||||
original = issue.get('original', {})
|
||||
if isinstance(original, dict):
|
||||
if original.get('trigger'):
|
||||
print(f" Trigger: {original['trigger'][:80]}...")
|
||||
if original.get('effect'):
|
||||
effect_text = original['effect']
|
||||
if len(effect_text) > 100:
|
||||
effect_text = effect_text[:100] + "..."
|
||||
print(f" Effect: {effect_text}")
|
||||
|
||||
if issue.get('parsed'):
|
||||
parsed = issue['parsed']
|
||||
print(f" Parsed type: {parsed.get('type')}")
|
||||
if parsed.get('effects'):
|
||||
print(f" Effects: {[e.get('type') for e in parsed['effects']]}")
|
||||
|
||||
if len(issues) > limit:
|
||||
print(f"\n... and {len(issues) - limit} more")
|
||||
|
||||
|
||||
def print_effect_analysis(abilities_data: dict, effect_type: str) -> None:
|
||||
"""Show all abilities with a specific effect type."""
|
||||
effect_type = effect_type.upper()
|
||||
abilities = abilities_data.get('abilities', {})
|
||||
|
||||
matches = []
|
||||
for card_id, card_abilities in abilities.items():
|
||||
for ability in card_abilities:
|
||||
parsed = ability.get('parsed', {})
|
||||
for effect in parsed.get('effects', []):
|
||||
if effect.get('type') == effect_type:
|
||||
matches.append({
|
||||
'card_id': card_id,
|
||||
'ability': ability,
|
||||
'effect': effect
|
||||
})
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f"EFFECT TYPE: {effect_type} ({len(matches)} occurrences)")
|
||||
print("=" * 70)
|
||||
|
||||
# Group by effect structure
|
||||
structures = defaultdict(list)
|
||||
for match in matches:
|
||||
effect = match['effect']
|
||||
# Create a structure key
|
||||
keys = sorted([k for k in effect.keys() if k != 'raw'])
|
||||
structure = tuple(keys)
|
||||
structures[structure].append(match)
|
||||
|
||||
print(f"\nStructure variations: {len(structures)}")
|
||||
for structure, examples in structures.items():
|
||||
print(f"\n Keys: {list(structure)}")
|
||||
print(f" Count: {len(examples)}")
|
||||
# Show one example
|
||||
ex = examples[0]
|
||||
print(f" Example ({ex['card_id']}): {ex['effect']}")
|
||||
|
||||
|
||||
def validate_card(abilities_data: dict, card_id: str, cards: dict) -> None:
|
||||
"""Show detailed validation for a specific card."""
|
||||
abilities = abilities_data.get('abilities', {})
|
||||
card_abilities = abilities.get(card_id, [])
|
||||
card = cards.get(card_id, {})
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f"CARD: {card_id} - {card.get('name', 'Unknown')}")
|
||||
print("=" * 70)
|
||||
|
||||
if not card_abilities:
|
||||
print("No abilities found for this card.")
|
||||
return
|
||||
|
||||
for i, ability in enumerate(card_abilities):
|
||||
print(f"\n--- Ability {i + 1} ---")
|
||||
|
||||
original = ability.get('original', {})
|
||||
print(f"Type: {original.get('type', 'unknown')}")
|
||||
if original.get('trigger'):
|
||||
print(f"Trigger: {original['trigger']}")
|
||||
if original.get('effect'):
|
||||
print(f"Effect: {original['effect']}")
|
||||
|
||||
print(f"\nConfidence: {ability.get('parse_confidence', 'UNKNOWN')}")
|
||||
|
||||
parsed = ability.get('parsed', {})
|
||||
print(f"Parsed Type: {parsed.get('type', 'none')}")
|
||||
|
||||
if parsed.get('trigger'):
|
||||
print(f"Parsed Trigger: {json.dumps(parsed['trigger'], indent=2)}")
|
||||
|
||||
if parsed.get('effects'):
|
||||
print("Parsed Effects:")
|
||||
for j, effect in enumerate(parsed['effects']):
|
||||
print(f" [{j+1}] {json.dumps(effect, indent=4)}")
|
||||
|
||||
|
||||
def export_issues(issues: dict, cards: dict, output_path: str) -> None:
|
||||
"""Export issues to a JSON file for external review/fixing."""
|
||||
export_data = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'summary': {
|
||||
'unparsed': len(issues['unparsed']),
|
||||
'low_confidence': len(issues['low_confidence']),
|
||||
'unknown_effects': len(issues['unknown_effects']),
|
||||
'missing_targets': len(issues['missing_targets']),
|
||||
'complex_conditions': len(issues['complex_conditions']),
|
||||
},
|
||||
'issues': {}
|
||||
}
|
||||
|
||||
# Group issues by card
|
||||
for issue_type, issue_list in issues.items():
|
||||
for issue in issue_list:
|
||||
card_id = issue['card_id']
|
||||
if card_id not in export_data['issues']:
|
||||
card = cards.get(card_id, {})
|
||||
export_data['issues'][card_id] = {
|
||||
'name': card.get('name', 'Unknown'),
|
||||
'problems': []
|
||||
}
|
||||
|
||||
export_data['issues'][card_id]['problems'].append({
|
||||
'type': issue_type,
|
||||
'ability_index': issue.get('ability_index', 0),
|
||||
'reason': issue.get('reason', ''),
|
||||
'original': issue.get('original', {})
|
||||
})
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(export_data, f, indent=2)
|
||||
|
||||
print(f"Exported {len(export_data['issues'])} cards with issues to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Validate processed abilities')
|
||||
parser.add_argument('--unparsed', action='store_true', help='Show unparsed abilities')
|
||||
parser.add_argument('--low-confidence', action='store_true', help='Show low confidence parses')
|
||||
parser.add_argument('--unknown', action='store_true', help='Show unknown effects')
|
||||
parser.add_argument('--card', type=str, help='Validate specific card by ID')
|
||||
parser.add_argument('--effect', type=str, help='Show all abilities with effect type')
|
||||
parser.add_argument('--summary', action='store_true', help='Show summary only')
|
||||
parser.add_argument('--export', type=str, help='Export issues to JSON file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
abilities_data = load_abilities()
|
||||
if not abilities_data:
|
||||
return
|
||||
|
||||
cards = load_cards()
|
||||
issues = get_issues(abilities_data)
|
||||
|
||||
# Always show summary
|
||||
print_summary(abilities_data, issues)
|
||||
|
||||
if args.summary:
|
||||
return
|
||||
|
||||
if args.card:
|
||||
validate_card(abilities_data, args.card, cards)
|
||||
return
|
||||
|
||||
if args.effect:
|
||||
print_effect_analysis(abilities_data, args.effect)
|
||||
return
|
||||
|
||||
if args.export:
|
||||
export_issues(issues, cards, args.export)
|
||||
return
|
||||
|
||||
# Show specific issue types
|
||||
if args.unparsed:
|
||||
print_issues(issues['unparsed'], "UNPARSED ABILITIES", cards)
|
||||
|
||||
if args.low_confidence:
|
||||
print_issues(issues['low_confidence'], "LOW CONFIDENCE PARSES", cards)
|
||||
|
||||
if args.unknown:
|
||||
print_issues(issues['unknown_effects'], "UNKNOWN EFFECTS", cards)
|
||||
|
||||
# If no specific flag, show a sample of each issue type
|
||||
if not any([args.unparsed, args.low_confidence, args.unknown]):
|
||||
print("\nUse --unparsed, --low-confidence, --unknown, --card, or --effect for details")
|
||||
print("Use --export issues.json to export all issues for review")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
373
tools/ai_card_reviewer.py
Normal file
373
tools/ai_card_reviewer.py
Normal file
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Card Reviewer - Uses Claude's vision to validate and correct card data.
|
||||
|
||||
Usage:
|
||||
python tools/ai_card_reviewer.py # Review all unreviewed cards
|
||||
python tools/ai_card_reviewer.py --set 1 # Review only Opus 1 cards
|
||||
python tools/ai_card_reviewer.py --card 1-001H # Review a specific card
|
||||
python tools/ai_card_reviewer.py --limit 10 # Review only 10 cards
|
||||
python tools/ai_card_reviewer.py --dry-run # Don't save changes, just show what would change
|
||||
|
||||
Requires:
|
||||
pip install anthropic
|
||||
|
||||
Set your API key:
|
||||
export ANTHROPIC_API_KEY=your-key-here
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
print("Error: anthropic package not installed. Run: pip install anthropic")
|
||||
sys.exit(1)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CARDS_FILE = PROJECT_ROOT / "data" / "cards.json"
|
||||
REVIEWED_FILE = PROJECT_ROOT / "data" / "reviewed.json"
|
||||
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
|
||||
|
||||
# Rate limiting
|
||||
REQUESTS_PER_MINUTE = 30
|
||||
REQUEST_DELAY = 60 / REQUESTS_PER_MINUTE
|
||||
|
||||
|
||||
def load_cards():
|
||||
with open(CARDS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_cards(data):
|
||||
with open(CARDS_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def load_reviewed():
|
||||
if REVIEWED_FILE.exists():
|
||||
with open(REVIEWED_FILE, "r") as f:
|
||||
return set(json.load(f).get("reviewed", []))
|
||||
return set()
|
||||
|
||||
|
||||
def save_reviewed(reviewed_set):
|
||||
with open(REVIEWED_FILE, "w") as f:
|
||||
json.dump({"reviewed": list(reviewed_set)}, f, indent=2)
|
||||
|
||||
|
||||
def encode_image(image_path: Path) -> tuple[str, str]:
|
||||
"""Encode image to base64 and return (data, media_type)."""
|
||||
with open(image_path, "rb") as f:
|
||||
data = base64.standard_b64encode(f.read()).decode("utf-8")
|
||||
|
||||
ext = image_path.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
media_type = "image/jpeg"
|
||||
elif ext == ".png":
|
||||
media_type = "image/png"
|
||||
elif ext == ".gif":
|
||||
media_type = "image/gif"
|
||||
elif ext == ".webp":
|
||||
media_type = "image/webp"
|
||||
else:
|
||||
media_type = "image/jpeg" # fallback
|
||||
|
||||
return data, media_type
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a data validator for Final Fantasy Trading Card Game (FFTCG) cards.
|
||||
You will be shown a card image and its current JSON data. Your job is to:
|
||||
|
||||
1. Verify all fields match what's visible on the card
|
||||
2. Correct any errors you find
|
||||
3. Fill in any missing data
|
||||
4. Ensure abilities are accurately transcribed
|
||||
|
||||
FFTCG Card Structure:
|
||||
- id: Card number (e.g., "1-001H" = Opus 1, card 001, Hero rarity)
|
||||
- name: Character/card name
|
||||
- type: Forward, Backup, Summon, or Monster
|
||||
- element: Fire, Ice, Wind, Earth, Lightning, Water, Light, or Dark (can be array for multi-element)
|
||||
- cost: Crystal Point cost (number in top-left)
|
||||
- power: Power value for Forwards (number at bottom, in thousands like 7000). Backups/Summons have null power.
|
||||
- job: Job class (e.g., "Warrior", "Knight")
|
||||
- category: Game title (e.g., "VII", "X", "TACTICS")
|
||||
- is_generic: true if card has no category (generic cards)
|
||||
- has_ex_burst: true if card has EX BURST ability (lightning bolt icon)
|
||||
- has_haste: true if the card has the Haste keyword ability (can attack/use abilities the turn it enters)
|
||||
|
||||
Ability Types:
|
||||
- field: Passive abilities always active (includes keyword abilities like Haste, Brave, First Strike)
|
||||
- auto: Triggered abilities (start with "When..." or have a trigger condition)
|
||||
- action: Activated abilities (have a cost, often require dulling with S symbol)
|
||||
- special: Special abilities (usually named abilities with S symbol cost)
|
||||
|
||||
Important Keywords to Identify:
|
||||
- Haste: "This card can attack and use abilities the turn it enters the field" - set has_haste=true
|
||||
- Brave: Card doesn't dull when attacking
|
||||
- First Strike: Deals damage before opponent in combat
|
||||
|
||||
Respond ONLY with valid JSON in this exact format:
|
||||
{
|
||||
"changes_made": true/false,
|
||||
"confidence": "high"/"medium"/"low",
|
||||
"notes": "Brief explanation of changes or issues",
|
||||
"corrected_data": {
|
||||
// Complete card JSON with all fields
|
||||
}
|
||||
}
|
||||
|
||||
If the data looks correct, set changes_made to false and return the original data in corrected_data.
|
||||
Always include ALL fields in corrected_data, even if unchanged."""
|
||||
|
||||
|
||||
def review_card(client: anthropic.Anthropic, card: dict, image_path: Path) -> dict:
|
||||
"""Review a single card using Claude's vision."""
|
||||
|
||||
image_data, media_type = encode_image(image_path)
|
||||
|
||||
user_message = f"""Please review this FFTCG card image and verify/correct the following JSON data:
|
||||
|
||||
```json
|
||||
{json.dumps(card, indent=2)}
|
||||
```
|
||||
|
||||
Look carefully at:
|
||||
1. Card name spelling
|
||||
2. Element (color of the crystal/card border)
|
||||
3. Cost (number in the crystal)
|
||||
4. Power (number at bottom for Forwards, should be null for Backups/Summons)
|
||||
5. Job and Category text
|
||||
6. All abilities - check type, name, trigger, effect text
|
||||
7. EX BURST indicator (lightning bolt symbol)
|
||||
8. Haste keyword - if the card mentions attacking or using abilities the turn it enters, has_haste should be true
|
||||
|
||||
Return the corrected JSON."""
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model="claude-opus-4-5-20251101",
|
||||
max_tokens=4096,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": image_data,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": user_message,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Extract JSON from response
|
||||
response_text = response.content[0].text
|
||||
|
||||
# Try to parse JSON (handle markdown code blocks)
|
||||
if "```json" in response_text:
|
||||
json_str = response_text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in response_text:
|
||||
json_str = response_text.split("```")[1].split("```")[0].strip()
|
||||
else:
|
||||
json_str = response_text.strip()
|
||||
|
||||
return json.loads(json_str)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" JSON parse error: {e}")
|
||||
print(f" Response: {response_text[:500]}...")
|
||||
return None
|
||||
except anthropic.APIError as e:
|
||||
print(f" API error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def print_diff(original: dict, corrected: dict, card_id: str):
|
||||
"""Print differences between original and corrected card data."""
|
||||
changes = []
|
||||
|
||||
# Compare top-level fields
|
||||
for key in set(list(original.keys()) + list(corrected.keys())):
|
||||
if key in ("abilities", "image"):
|
||||
continue
|
||||
orig_val = original.get(key)
|
||||
corr_val = corrected.get(key)
|
||||
if orig_val != corr_val:
|
||||
changes.append(f" {key}: {orig_val!r} -> {corr_val!r}")
|
||||
|
||||
# Compare abilities (simplified)
|
||||
orig_abilities = original.get("abilities", [])
|
||||
corr_abilities = corrected.get("abilities", [])
|
||||
|
||||
if len(orig_abilities) != len(corr_abilities):
|
||||
changes.append(f" abilities: {len(orig_abilities)} -> {len(corr_abilities)} abilities")
|
||||
else:
|
||||
for i, (orig_ab, corr_ab) in enumerate(zip(orig_abilities, corr_abilities)):
|
||||
if orig_ab != corr_ab:
|
||||
changes.append(f" abilities[{i}]: modified")
|
||||
|
||||
if changes:
|
||||
print(f"\n[{card_id}] Changes:")
|
||||
for change in changes:
|
||||
print(change)
|
||||
else:
|
||||
print(f"[{card_id}] No changes needed")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AI Card Reviewer using Claude Vision")
|
||||
parser.add_argument("--set", type=str, help="Only review cards from this set/opus (e.g., '1' for Opus 1)")
|
||||
parser.add_argument("--card", type=str, help="Review a specific card by ID")
|
||||
parser.add_argument("--limit", type=int, help="Maximum number of cards to review")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Don't save changes, just show what would change")
|
||||
parser.add_argument("--include-reviewed", action="store_true", help="Re-review already reviewed cards")
|
||||
parser.add_argument("--auto-mark-reviewed", action="store_true", help="Automatically mark cards as reviewed after AI review")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check API key
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: ANTHROPIC_API_KEY environment variable not set")
|
||||
print("Set it with: export ANTHROPIC_API_KEY=your-key-here")
|
||||
sys.exit(1)
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
# Load data
|
||||
cards_data = load_cards()
|
||||
reviewed_set = load_reviewed()
|
||||
all_cards = cards_data["cards"]
|
||||
|
||||
print(f"Loaded {len(all_cards)} cards, {len(reviewed_set)} already reviewed")
|
||||
|
||||
# Filter cards to review
|
||||
cards_to_review = []
|
||||
for card in all_cards:
|
||||
# Skip if already reviewed (unless --include-reviewed)
|
||||
if not args.include_reviewed and card["id"] in reviewed_set:
|
||||
continue
|
||||
|
||||
# Filter by set if specified
|
||||
if args.set and not card["id"].startswith(args.set + "-"):
|
||||
continue
|
||||
|
||||
# Filter by specific card if specified
|
||||
if args.card and card["id"] != args.card:
|
||||
continue
|
||||
|
||||
# Check image exists
|
||||
image_path = SOURCE_CARDS_DIR / card.get("image", "")
|
||||
if not image_path.exists():
|
||||
print(f"Warning: Image not found for {card['id']}: {image_path}")
|
||||
continue
|
||||
|
||||
cards_to_review.append((card, image_path))
|
||||
|
||||
# Apply limit
|
||||
if args.limit:
|
||||
cards_to_review = cards_to_review[:args.limit]
|
||||
|
||||
if not cards_to_review:
|
||||
print("No cards to review matching criteria")
|
||||
return
|
||||
|
||||
print(f"\nReviewing {len(cards_to_review)} cards...")
|
||||
if args.dry_run:
|
||||
print("(DRY RUN - no changes will be saved)")
|
||||
print()
|
||||
|
||||
# Track statistics
|
||||
stats = {
|
||||
"reviewed": 0,
|
||||
"changed": 0,
|
||||
"errors": 0,
|
||||
"high_confidence": 0,
|
||||
"medium_confidence": 0,
|
||||
"low_confidence": 0,
|
||||
}
|
||||
|
||||
# Review each card
|
||||
for i, (card, image_path) in enumerate(cards_to_review):
|
||||
print(f"[{i+1}/{len(cards_to_review)}] Reviewing {card['id']}: {card.get('name', 'Unknown')}...", end="", flush=True)
|
||||
|
||||
result = review_card(client, card, image_path)
|
||||
|
||||
if result is None:
|
||||
print(" ERROR")
|
||||
stats["errors"] += 1
|
||||
time.sleep(REQUEST_DELAY)
|
||||
continue
|
||||
|
||||
stats["reviewed"] += 1
|
||||
confidence = result.get("confidence", "unknown")
|
||||
stats[f"{confidence}_confidence"] = stats.get(f"{confidence}_confidence", 0) + 1
|
||||
|
||||
if result.get("changes_made"):
|
||||
stats["changed"] += 1
|
||||
print(f" CHANGED ({confidence} confidence)")
|
||||
print(f" Notes: {result.get('notes', 'No notes')}")
|
||||
|
||||
corrected = result.get("corrected_data", {})
|
||||
print_diff(card, corrected, card["id"])
|
||||
|
||||
if not args.dry_run:
|
||||
# Update card in data
|
||||
for j, c in enumerate(cards_data["cards"]):
|
||||
if c["id"] == card["id"]:
|
||||
# Preserve image path
|
||||
corrected["image"] = card.get("image", "")
|
||||
cards_data["cards"][j] = corrected
|
||||
break
|
||||
else:
|
||||
print(f" OK ({confidence} confidence)")
|
||||
|
||||
# Mark as reviewed if auto-mark enabled
|
||||
if args.auto_mark_reviewed and not args.dry_run:
|
||||
reviewed_set.add(card["id"])
|
||||
|
||||
# Rate limiting
|
||||
if i < len(cards_to_review) - 1:
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
# Save changes
|
||||
if not args.dry_run and stats["changed"] > 0:
|
||||
print(f"\nSaving changes to {CARDS_FILE}...")
|
||||
save_cards(cards_data)
|
||||
|
||||
if not args.dry_run and args.auto_mark_reviewed:
|
||||
print(f"Saving reviewed status to {REVIEWED_FILE}...")
|
||||
save_reviewed(reviewed_set)
|
||||
|
||||
# Print summary
|
||||
print(f"\n{'='*50}")
|
||||
print("Summary:")
|
||||
print(f" Cards reviewed: {stats['reviewed']}")
|
||||
print(f" Cards changed: {stats['changed']}")
|
||||
print(f" Errors: {stats['errors']}")
|
||||
print(f" High confidence: {stats['high_confidence']}")
|
||||
print(f" Medium confidence: {stats['medium_confidence']}")
|
||||
print(f" Low confidence: {stats['low_confidence']}")
|
||||
|
||||
if args.dry_run and stats["changed"] > 0:
|
||||
print(f"\n(Dry run - {stats['changed']} changes NOT saved)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -337,6 +337,10 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
|
||||
<input type="checkbox" id="fieldExBurst" data-field="has_ex_burst">
|
||||
<label for="fieldExBurst">EX Burst</label>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="fieldHaste" data-field="has_haste">
|
||||
<label for="fieldHaste">Haste</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="abilities-section">
|
||||
@@ -585,6 +589,7 @@ function showCardInModal(index) {
|
||||
document.getElementById('fieldCategory').value = card.category || '';
|
||||
document.getElementById('fieldGeneric').checked = !!card.is_generic;
|
||||
document.getElementById('fieldExBurst').checked = !!card.has_ex_burst;
|
||||
document.getElementById('fieldHaste').checked = !!card.has_haste;
|
||||
|
||||
const abList = document.getElementById('abilitiesList');
|
||||
abList.innerHTML = '';
|
||||
@@ -683,6 +688,7 @@ function getCurrentEdits() {
|
||||
category: document.getElementById('fieldCategory').value,
|
||||
is_generic: document.getElementById('fieldGeneric').checked,
|
||||
has_ex_burst: document.getElementById('fieldExBurst').checked,
|
||||
has_haste: document.getElementById('fieldHaste').checked,
|
||||
abilities: getAbilitiesFromDOM(),
|
||||
image: card.image,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user