Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
class="upgrade-btn"
*ngIf="towerInfo!.level < MAX_TOWER_LEVEL"
[class.unaffordable]="gold < upgradeCost"
[disabled]="gold < upgradeCost"
(click)="upgrade.emit()"
aria-label="Upgrade tower">
Upgrade ({{ upgradeCost }}g)<span class="upgrade-tile-percent" *ngIf="upgradePercent > 0"> +{{ upgradePercent }}%</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ describe('TowerInfoPanelComponent', () => {
expect(btn.classList.contains('unaffordable')).toBeTrue();
});

it('should disable the upgrade button when gold is less than upgrade cost', () => {
component.towerInfo = makeTower({ level: 1 });
component.upgradeCost = 200;
component.gold = 50;
fixture.detectChanges();
const btn: HTMLButtonElement = fixture.nativeElement.querySelector('.upgrade-btn');
expect(btn.disabled).toBeTrue();
});

it('should not disable the upgrade button when gold meets the upgrade cost', () => {
component.towerInfo = makeTower({ level: 1 });
component.upgradeCost = 75;
component.gold = 75;
fixture.detectChanges();
const btn: HTMLButtonElement = fixture.nativeElement.querySelector('.upgrade-btn');
expect(btn.disabled).toBeFalse();
});

it('should emit upgrade event when upgrade button clicked', () => {
component.towerInfo = makeTower({ level: 1 });
fixture.detectChanges();
Expand Down
47 changes: 40 additions & 7 deletions src/app/game/game-board/game-board.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
type="button"
class="hud-relic-peek__item"
[attr.title]="relic.name + ' — ' + relic.description"
[attr.aria-label]="relic.name + ': ' + relic.description">
[attr.aria-label]="relic.name + ': ' + relic.description"
(click)="togglePause()">
{{ relic.name.charAt(0) }}
</button>
</div>
Expand All @@ -43,7 +44,8 @@
type="button"
class="hud-ascension-peek__item"
[attr.title]="mod.label + ' — ' + mod.description"
[attr.aria-label]="mod.label + ': ' + mod.description">
[attr.aria-label]="mod.label + ': ' + mod.description"
(click)="togglePause()">
{{ mod.label.charAt(0) }}
</button>
</div>
Expand Down Expand Up @@ -166,16 +168,26 @@
</ng-template>
</div>

<!-- Tile-target mode indicator — shown while a terraform or elevation card awaits a tile click -->
<div class="placement-indicator placement-indicator--tile-target"
*ngIf="pendingTileTargetCardId !== null"
role="status"
aria-live="polite">
<app-icon name="crosshair" [size]="16" aria-hidden="true"></app-icon>
<span class="placement-indicator__text">Click a tile to target</span>
<span class="placement-indicator__hint">Right-click to cancel</span>
</div>

<!-- Phase 4: End Turn button — only during combat (turn-based). -->
<button
class="end-turn-btn"
*ngIf="gameState.phase === GamePhase.COMBAT"
[class.end-turn-btn--stuck]="handStuck && !pendingTowerCardId"
[class.end-turn-btn--stuck]="handStuck && !hasPendingCard"
[class.end-turn-btn--leak-warning]="projectedLeaksNextTurn > 0"
(click)="endTurn()"
[disabled]="!!pendingTowerCardId"
[attr.aria-label]="pendingTowerCardId
? 'Place or cancel pending tower card first'
[disabled]="hasPendingCard"
[attr.aria-label]="hasPendingCard
? 'Place or cancel pending card first'
: (projectedLeaksNextTurn > 0
? 'End turn and resolve combat. Warning: ' + projectedLeakerSummary + ' projected to leak next turn.'
: 'End turn and resolve combat')"
Expand All @@ -185,6 +197,9 @@
<span class="end-turn-leak-warning"
*ngIf="projectedLeaksNextTurn > 0"
aria-hidden="true">⚠ {{ projectedLeaksNextTurn }} leak<ng-container *ngIf="projectedLeaksNextTurn !== 1">s</ng-container></span>
<span class="end-turn-stuck-hint"
*ngIf="handStuck && !hasPendingCard"
aria-hidden="true">No playable cards — end your turn</span>
</button>

<!-- Boss-intro banner — fires once when a wave containing a boss-tier enemy begins.
Expand Down Expand Up @@ -214,6 +229,23 @@
[records]="recentTurnRecords">
</app-last-turn-summary>

<!-- Active card-modifier buffs — persists through combat until each buff expires. -->
<aside
class="active-buffs"
*ngIf="activeBuffs.length > 0"
aria-label="Active buffs"
aria-live="polite">
<h4 class="active-buffs__title">Active Buffs</h4>
<ul class="active-buffs__list">
<li
*ngFor="let chip of activeBuffs; trackBy: trackBuffByIndex; let i = index"
class="active-buffs__chip">
<span class="active-buffs__label">{{ chip.label }}</span>
<span class="active-buffs__remaining">{{ chip.remaining }}</span>
</li>
</ul>
</aside>

<!-- Phase 4: Spawn preview — shows what's coming in the next few turns. -->
<aside
class="spawn-preview"
Expand Down Expand Up @@ -356,6 +388,7 @@ <h5 class="spawn-preview__subtitle">Scouted</h5>
[class.toast--challenge]="notification.type === 'challenge'"
[class.toast--streak]="notification.type === 'streak'"
[class.toast--info]="notification.type === 'info'"
[style.animation-duration.ms]="notification.duration"
(click)="dismissNotification(notification.id)"
role="alert"
>
Expand All @@ -372,7 +405,7 @@ <h5 class="spawn-preview__subtitle">Scouted</h5>
[deckState]="deckState"
[energy]="energyState"
[currentGold]="gameState.gold"
[pendingCardId]="pendingTowerCardId"
[pendingCardId]="pendingTowerCardId ?? pendingTileTargetCardId"
(pileInspected)="inspectedPile = $event"
(cardPlayed)="onCardPlayed($event)"
(cardInspected)="inspectedCard = $event"
Expand Down
93 changes: 93 additions & 0 deletions src/app/game/game-board/game-board.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
--shell-recap-top: var(--shell-spawn-top);
--shell-recap-right: var(--spacing-sm);
--shell-tower-info-bottom: var(--spacing-sm);
// Active-buffs panel sits beneath INCOMING on the left. 14rem clears
// a fully-populated spawn-preview (title + 4 turn rows + scouted section).
--shell-buffs-top: calc(var(--shell-spawn-top) + 14rem);

// When the wave-preview panel is mounted (INTERMISSION / SETUP phases), it
// owns the top-right corner. The RECAP sits right under it — max-height of
Expand Down Expand Up @@ -318,6 +321,65 @@
.enemy-dot[data-type='flying'] { background: var(--enemy-color-flying); box-shadow: 0 0 4px var(--enemy-glow-flying); }
}

// --- Active buffs panel ---
// Positioned below the INCOMING spawn-preview on the left side. Shares the
// glass-panel treatment and font scale of .spawn-preview so the two panels
// read as a visual family. Hidden on mobile (same breakpoint as .spawn-preview).
.active-buffs {
@include glass-panel(rgba(10, 15, 30, 0.82));
position: absolute;
top: var(--shell-buffs-top);
left: var(--shell-spawn-left);
padding: var(--spacing-sm) var(--spacing-md);
border: var(--border-width-thin) solid var(--panel-blue-border);
color: var(--text-color);
pointer-events: none;
z-index: var(--z-index-overlay);

@media (max-width: 768px) {
display: none;
}

.active-buffs__title {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
opacity: 0.7;
font-weight: 600;
}

.active-buffs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}

.active-buffs__chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
font-size: var(--font-size-xs);
}

.active-buffs__label {
font-weight: 700;
color: var(--panel-blue-accent);
}

.active-buffs__remaining {
font-size: var(--font-size-3xs);
font-weight: 500;
color: var(--text-muted);
opacity: 0.7;
letter-spacing: 0.025em;
}
}

// Colorblind-assist mode: enemy dots switch to the deuteranopia/protanopia-safe
// palette (mirrored from COLORBLIND_ENEMY_PALETTE in colorblind.constants.ts)
// and gain a letter-initial shape cue so type is never conveyed by color alone.
Expand Down Expand Up @@ -430,6 +492,16 @@
text-transform: uppercase;
margin-top: 0.125rem;
}

.end-turn-stuck-hint {
display: block;
font-size: var(--font-size-2xs);
font-weight: 400;
letter-spacing: 0.04em;
color: var(--text-color-muted, rgba(255, 255, 255, 0.55));
text-transform: none;
margin-top: 0.1rem;
}
}

@keyframes end-turn-stuck-pulse {
Expand Down Expand Up @@ -836,16 +908,37 @@ body.reduce-motion .board-container .end-combat-overlay__label { animation: none
}
}

// Tile-target mode variant (terraform / elevation cards): cyan instead of gold
// to distinguish "pick a tile" from "pick a tile to place tower".
.board-container .placement-indicator--tile-target {
background: rgba(0, 220, 220, 0.1);
border-color: rgba(0, 220, 220, 0.5);
color: var(--theme-cyan, #00dcdc);
box-shadow: 0 0 1rem rgba(0, 220, 220, 0.2);
animation: tile-target-indicator-pulse 1.6s ease-in-out infinite;

.placement-indicator__text {
color: var(--theme-cyan, #00dcdc);
}
}

@keyframes placement-indicator-pulse {
0%, 100% { box-shadow: 0 0 1rem rgba(255, 215, 0, 0.2); border-color: rgba(255, 215, 0, 0.5); }
50% { box-shadow: 0 0 1.5rem rgba(255, 215, 0, 0.45); border-color: rgba(255, 215, 0, 0.8); }
}

@keyframes tile-target-indicator-pulse {
0%, 100% { box-shadow: 0 0 1rem rgba(0, 220, 220, 0.2); border-color: rgba(0, 220, 220, 0.5); }
50% { box-shadow: 0 0 1.5rem rgba(0, 220, 220, 0.45); border-color: rgba(0, 220, 220, 0.8); }
}

@media (prefers-reduced-motion: reduce) {
.board-container .placement-indicator { animation: none; }
.board-container .placement-indicator--tile-target { animation: none; }
}

body.reduce-motion .board-container .placement-indicator { animation: none; }
body.reduce-motion .board-container .placement-indicator--tile-target { animation: none; }

@media (max-width: 480px) {
.board-container .placement-indicator {
Expand Down
61 changes: 61 additions & 0 deletions src/app/game/game-board/game-board.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import { TutorialFacadeService } from './services/tutorial-facade.service';
import { ItemCallbacksWiringService } from './services/item-callbacks-wiring.service';
import { EncounterBootstrapService } from './services/encounter-bootstrap.service';
import { UI_CONFIG } from './constants/ui.constants';
import { CardEffectService, ActiveModifier } from '../../run/services/card-effect.service';
import { MODIFIER_STAT } from '../../run/constants/modifier-stat.constants';

// ---------------------------------------------------------------------------
// Test-only interfaces — allow typed access to private fields/methods without
Expand Down Expand Up @@ -109,6 +111,7 @@ interface TestableGameBoardComponent {
detectWebgl(): boolean;
renderGameBoard(): void;
addGridLines(): void;
onTilePlace(row: number, col: number): void;
}

/** Exposes the internal challengeTrackingService field of TowerInteractionService. */
Expand Down Expand Up @@ -3124,5 +3127,63 @@ describe('GameBoardComponent', () => {
}));
});

describe('activeBuffs getter', () => {
it('returns an empty array when no modifiers are active', () => {
const cardEffect = TestBed.inject(CardEffectService);
spyOn(cardEffect, 'getActiveModifiers').and.returnValue([]);
expect(component.activeBuffs).toEqual([]);
});

it('maps a damage modifier to a buff chip with percent label', () => {
const cardEffect = TestBed.inject(CardEffectService);
const modifier: ActiveModifier = {
stat: MODIFIER_STAT.DAMAGE,
value: 0.25,
remainingWaves: 2,
};
spyOn(cardEffect, 'getActiveModifiers').and.returnValue([modifier]);
const chips = component.activeBuffs;
expect(chips.length).toBe(1);
expect(chips[0].label).toBe('Damage +25%');
expect(chips[0].remaining).toBe('2 waves');
});

it('excludes internal sentinel stats from the chips array', () => {
const cardEffect = TestBed.inject(CardEffectService);
const modifiers: ActiveModifier[] = [
// Internal per-turn flag — never surfaced to the player (not in BUFF_META).
{ stat: MODIFIER_STAT.TERRAFORM_REFUND_USED_THIS_TURN, value: 1, remainingWaves: null, remainingTurns: 1 },
{ stat: MODIFIER_STAT.RANGE, value: 0.2, remainingWaves: 1 },
];
spyOn(cardEffect, 'getActiveModifiers').and.returnValue(modifiers);
const chips = component.activeBuffs;
expect(chips.length).toBe(1);
expect(chips[0].stat).toBe(MODIFIER_STAT.RANGE);
});
});

describe('PX-01: onTilePlace routes elevation-target card to resolveTileTarget', () => {
it('calls cardPlayService.resolveTileTarget when an elevation-target card is pending and selectedTowerType is null', () => {
const cardPlaySvc = fixture.debugElement.injector.get(CardPlayService);
// Place an elevation-target card in pending state directly
cardPlaySvc['pendingElevationTargetCard'] = {
instanceId: 'elev_inst',
cardId: CardId.RAISE_PLATFORM,
upgraded: false,
} as CardInstance;
// selectedTowerType must be null for the tile-target branch to fire
component.selectedTowerType = null;

const resolveSpy = spyOn(cardPlaySvc, 'resolveTileTarget').and.returnValue({ ok: true });

(component as unknown as TestableGameBoardComponent).onTilePlace(2, 3);

expect(resolveSpy).toHaveBeenCalled();
const [row, col] = resolveSpy.calls.mostRecent().args;
expect(row).toBe(2);
expect(col).toBe(3);
});
});

});

Loading
Loading