import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { within } from "../../../core/Util"; import { SendDonateGoldIntentEvent, SendDonateTroopsIntentEvent, } from "../../Transport"; import { renderTroops, translateText } from "../../Utils"; import { UIState } from "../UIState"; @customElement("send-resource-modal") export class SendResourceModal extends LitElement { @property({ attribute: false }) eventBus: EventBus | null = null; @property({ type: Boolean }) open: boolean = false; @property({ type: String }) mode: "troops" | "gold" = "troops"; @property({ type: Object }) total: number | bigint = 0; @property({ type: Object }) uiState: UIState | null = null; // to seed initial % @property({ attribute: false }) format: (n: number) => string = renderTroops; @property({ attribute: false }) myPlayer: PlayerView | null = null; @property({ attribute: false }) target: PlayerView | null = null; @property({ attribute: false }) gameView: GameView | null = null; @property({ type: String }) heading: string | null = null; @state() private sendAmount: number = 0; @state() private selectedPercent: number | null = null; private PRESETS = [10, 25, 50, 75, 100] as const; createRenderRoot() { return this; } connectedCallback() { super.connectedCallback(); const initPct = this.uiState && typeof this.uiState.attackRatio === "number" ? Math.round(this.uiState.attackRatio * 100) : 100; this.selectedPercent = this.sanitizePercent(initPct); const basis = this.getPercentBasis(); this.sendAmount = this.clampSend( Math.floor((basis * this.selectedPercent) / 100), ); } updated(changed: Map) { if (changed.has("open") && this.open) { // If either side is dead, just close and do nothing if (!this.isSenderAlive() || !this.isTargetAlive()) { this.closeModal(); return; } queueMicrotask(() => (this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(), ); } if ( changed.has("total") || changed.has("mode") || changed.has("target") || changed.has("gameView") ) { const basis = this.getPercentBasis(); if (this.selectedPercent !== null) { const pct = this.sanitizePercent(this.selectedPercent); const raw = Math.floor((basis * pct) / 100); this.sendAmount = this.clampSend(raw); } else { this.sendAmount = this.clampSend(this.sendAmount); } } } private closeModal() { this.dispatchEvent(new CustomEvent("close")); } private confirm() { if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) { return; } const myPlayer = this.myPlayer; const target = this.target; const amount = this.limitAmount(this.sendAmount); if (!myPlayer || !target || amount <= 0) return; if (this.mode === "troops") { const myTroops = Number(myPlayer.troops()); if (amount > myTroops) return; this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount)); } else { const myGold = Number(myPlayer.gold()); if (amount > myGold) return; this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount))); } this.dispatchEvent( new CustomEvent("confirm", { detail: { amount, closePanel: true, success: true }, }), ); this.closeModal(); } private handleKeydown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); this.closeModal(); } if (e.key === "Enter") { e.preventDefault(); this.confirm(); } }; private toNum(x: unknown): number { if (typeof x === "bigint") return Number(x); return Number(x ?? 0); } private getTotalNumber(): number { const base = this.toNum(this.total); return this.isSenderAlive() ? base : 0; } private sanitizePercent(p: number) { return within(p, 0, 100); } /** Internal capacity only for troops; gold is unlimited. */ private getCapacityLeft(): number | null { if (!this.isTargetAlive()) return 0; if (this.mode !== "troops") return null; if (!this.gameView || !this.target) return null; const current = this.toNum(this.target.troops()); const max = this.toNum(this.gameView.config().maxTroops(this.target)); return Math.max(0, max - current); } private getPercentBasis(): number { return this.getTotalNumber(); } private limitAmount(proposed: number): number { const cap = this.getCapacityLeft(); const total = this.getTotalNumber(); const hardMax = cap === null ? total : Math.min(total, cap); return within(proposed, 0, hardMax); } private clampSend(n: number) { const total = this.getTotalNumber(); const byTotal = within(n, 0, total); return this.limitAmount(byTotal); } private percentOfBasis(n: number): number { const basis = this.getPercentBasis(); return basis ? Math.round((n / basis) * 100) : 0; } private keepAfter(allowed: number): number { const total = this.getTotalNumber(); return Math.max(0, total - allowed); } private getFillColor(): string { return this.mode === "troops" ? "rgb(168 85 247)" /* purple */ : "rgb(234 179 8)" /* amber */; } private getMinKeepRatio(): number { return this.mode === "troops" ? 0.3 : 0; } private isTargetAlive(): boolean { return this.target?.isAlive() ?? false; } private isSenderAlive(): boolean { return this.myPlayer?.isAlive() ?? false; } private i18n = { title: (name: string) => this.mode === "troops" ? translateText("send_troops_modal.title_with_name", { name }) : translateText("send_gold_modal.title_with_name", { name }), availableChip: () => translateText("common.available"), availableTooltip: () => this.mode === "troops" ? translateText("send_troops_modal.available_tooltip") : translateText("send_gold_modal.available_tooltip"), max: () => translateText("common.preset_max"), ariaSlider: () => this.mode === "troops" ? translateText("send_troops_modal.aria_slider") : translateText("send_gold_modal.aria_slider"), summarySend: () => translateText("common.summary_send"), summaryKeep: () => translateText("common.summary_keep"), closeLabel: () => translateText("common.close"), cancel: () => translateText("common.cancel"), send: () => translateText("common.send"), cap: () => translateText("common.cap_label"), capTooltip: () => translateText("common.cap_tooltip"), sliderTooltip: (percent: number, amountStr: string) => this.mode === "troops" ? translateText("send_troops_modal.slider_tooltip", { percent, amount: amountStr, }) : translateText("send_gold_modal.slider_tooltip", { percent, amount: amountStr, }), capacityNote: (amountStr: string) => translateText("send_troops_modal.capacity_note", { amount: amountStr }), targetDeadTitle: () => translateText("common.target_dead"), targetDeadNote: () => translateText("common.target_dead_note"), }; private renderHeader() { const name = this.target?.name?.() ?? ""; return html`

${this.heading ?? this.i18n.title(name)}

`; } private renderAvailable() { const total = this.getTotalNumber(); return html`
${this.i18n.availableChip()} ${this.format(total)}
`; } private renderPresets(percentNow: number) { const basis = this.getTotalNumber(); const dead = !this.isSenderAlive() || !this.isTargetAlive(); return html`
${this.PRESETS.map((p) => { const pct = this.sanitizePercent(p); const active = (this.selectedPercent ?? percentNow) === pct; const label = pct === 100 ? this.i18n.max() : `${pct}%`; return html` `; })}
`; } private renderSlider(percentNow: number) { const basis = this.getTotalNumber(); const cap = this.getCapacityLeft(); const hardMax = cap === null ? basis : Math.min(basis, cap); const dead = !this.isSenderAlive() || !this.isTargetAlive(); // Where to draw the cap marker (as % of Available) const capPercent = cap === null ? null : Math.max( 0, Math.min( 100, Math.round((Math.min(cap, basis) / (basis || 1)) * 100), ), ); const fill = this.getFillColor(); const disabled = basis <= 0 || dead; const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2"; return html`
{ if (dead) return; const raw = Number((e.target as HTMLInputElement).value); const pctRaw = basis ? Math.round((raw / basis) * 100) : 0; this.selectedPercent = this.sanitizePercent(pctRaw); const clamped = Math.min(raw, hardMax); this.sendAmount = this.clampSend(clamped); }} class="w-full appearance-none bg-transparent range-x focus:outline-hidden" aria-label=${this.i18n.ariaSlider()} aria-valuemin="0" aria-valuemax=${hardMax} aria-valuetext=${this.i18n.sliderTooltip( percentNow, this.format(this.sendAmount), )} style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);" />
${percentNow}% • ${this.format(this.sendAmount)}
${capPercent !== null ? html`
${this.i18n.cap()}
` : html``}
`; } private renderCapacityNote(allowed: number) { const capped = allowed !== this.sendAmount; if (!capped) return html``; return html`

${this.i18n.capacityNote(this.format(allowed))}

`; } private renderSummary(allowed: number) { const total = this.getTotalNumber(); const keep = this.keepAfter(allowed); const belowMinKeep = this.getMinKeepRatio() > 0 && keep < Math.floor(total * this.getMinKeepRatio()); return html`
${this.i18n.summarySend()} ${this.format(allowed)} · ${this.i18n.summaryKeep()} ${this.format(keep)}
`; } private renderActions() { const total = this.getTotalNumber(); const dead = !this.isSenderAlive() || !this.isTargetAlive(); const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead; return html`
`; } private renderDeadNote() { return html`
${this.i18n.targetDeadTitle()}
${this.i18n.targetDeadNote()}
`; } private renderSliderStyles() { return html` `; } render() { if (!this.open) return html``; const percent = this.percentOfBasis(this.sendAmount); const allowed = this.limitAmount(this.sendAmount); return html`
this.closeModal()} >
`; } }