Economy: free vs paid games + buy-coins page; friends remove confirmation
- Coins only matter for ranked: free games (vs computer / private friend rooms) cost nothing; random ranked requires an entry (stake), gated by balance → routes to buy-coins when short - Buy Coins page (CoinPack/getCoinPacks/buyCoins; mock credits now, real Zarinpal/IDPay TODO); TopBar coins → buy; lobby create-room is Free - Friends: removed instant red ✕ delete; UserMinus → inline confirm before remove Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,20 @@ const fa: Dict = {
|
||||
"common.copy": "کپی",
|
||||
"common.copied": "کپی شد",
|
||||
"common.free": "رایگان",
|
||||
"common.yes": "بله",
|
||||
"common.no": "خیر",
|
||||
|
||||
"buy.title": "خرید سکه",
|
||||
"buy.note": "پرداخت امن — درگاه پرداخت ایرانی بهزودی اضافه میشود",
|
||||
"buy.toman": "تومان",
|
||||
"buy.bonus": "هدیه",
|
||||
"buy.popular": "محبوب",
|
||||
"buy.best": "بهترین",
|
||||
|
||||
"lobby.entry": "ورودی",
|
||||
"lobby.free": "رایگان",
|
||||
"lobby.needCoins": "سکه کافی نیست — شارژ کنید",
|
||||
"friends.removeQ": "این دوست حذف شود؟",
|
||||
|
||||
"chat.title": "گفتگو",
|
||||
"chat.placeholder": "پیام بنویسید…",
|
||||
@@ -316,6 +330,20 @@ const en: Dict = {
|
||||
"common.copy": "Copy",
|
||||
"common.copied": "Copied",
|
||||
"common.free": "Free",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
|
||||
"buy.title": "Buy Coins",
|
||||
"buy.note": "Secure payment — Iranian gateway coming soon",
|
||||
"buy.toman": "Toman",
|
||||
"buy.bonus": "bonus",
|
||||
"buy.popular": "Popular",
|
||||
"buy.best": "Best value",
|
||||
|
||||
"lobby.entry": "Entry",
|
||||
"lobby.free": "Free",
|
||||
"lobby.needCoins": "Not enough coins — top up",
|
||||
"friends.removeQ": "Remove this friend?",
|
||||
|
||||
"chat.title": "Chat",
|
||||
"chat.placeholder": "Type a message…",
|
||||
|
||||
@@ -104,10 +104,11 @@ export function ratingDelta(
|
||||
/* ------------------------------- Coins ------------------------------- */
|
||||
|
||||
export function coinDelta(summary: MatchSummary): number {
|
||||
const base = summary.won ? (summary.ranked ? 50 : 25) : 10;
|
||||
const stakeNet = summary.won ? summary.stake : -summary.stake;
|
||||
// Free games (vs computer / private friend rooms) never touch coins.
|
||||
if (!summary.ranked) return 0;
|
||||
// Ranked: win the stake (+kot bonus), lose the stake.
|
||||
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
|
||||
return base + stakeNet + kotBonus;
|
||||
return (summary.won ? summary.stake : -summary.stake) + kotBonus;
|
||||
}
|
||||
|
||||
/* ------------------------------- XP ---------------------------------- */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AppNotification,
|
||||
AuthSession,
|
||||
ChatMessage,
|
||||
CoinPack,
|
||||
Conversation,
|
||||
DailyRewardState,
|
||||
Friend,
|
||||
@@ -772,6 +773,26 @@ export class MockOnlineService implements OnlineService {
|
||||
|
||||
/* --------------------- leaderboard / shop / daily ------------------ */
|
||||
|
||||
async getCoinPacks(): Promise<CoinPack[]> {
|
||||
return [
|
||||
{ id: "p1", coins: 1000, bonus: 0, priceToman: 19000 },
|
||||
{ id: "p2", coins: 5000, bonus: 500, priceToman: 89000, tag: "popular" },
|
||||
{ id: "p3", coins: 12000, bonus: 2000, priceToman: 179000, tag: "best" },
|
||||
{ id: "p4", coins: 30000, bonus: 7000, priceToman: 399000 },
|
||||
];
|
||||
}
|
||||
|
||||
async buyCoins(packId: string) {
|
||||
const p = await this.getProfile();
|
||||
const pack = (await this.getCoinPacks()).find((x) => x.id === packId);
|
||||
if (!pack) return { ok: false, coins: 0 };
|
||||
// NOTE: real payment (Zarinpal/IDPay) goes here. For now we credit instantly.
|
||||
const added = pack.coins + pack.bonus;
|
||||
this.profile = { ...p, coins: p.coins + added };
|
||||
this.saveProfile();
|
||||
return { ok: true, profile: this.profile, coins: added };
|
||||
}
|
||||
|
||||
private onlineCount = 600 + Math.floor(Math.random() * 900);
|
||||
async getOnlineCount(): Promise<number> {
|
||||
// gentle random walk so the badge feels alive
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AppNotification,
|
||||
AuthSession,
|
||||
ChatMessage,
|
||||
CoinPack,
|
||||
Conversation,
|
||||
DailyRewardState,
|
||||
Friend,
|
||||
@@ -111,6 +112,10 @@ export interface OnlineService {
|
||||
buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>;
|
||||
getDailyState(): Promise<DailyRewardState>;
|
||||
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }>;
|
||||
|
||||
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
|
||||
getCoinPacks(): Promise<CoinPack[]>;
|
||||
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number }>;
|
||||
}
|
||||
|
||||
import { MockOnlineService } from "./mock-service";
|
||||
|
||||
@@ -293,4 +293,6 @@ export class SignalrService implements OnlineService {
|
||||
buyItem(id: string) { return this.mock.buyItem(id); }
|
||||
getDailyState(): Promise<DailyRewardState> { return this.mock.getDailyState(); }
|
||||
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); }
|
||||
getCoinPacks() { return this.mock.getCoinPacks(); }
|
||||
buyCoins(id: string) { return this.mock.buyCoins(id); }
|
||||
}
|
||||
|
||||
@@ -327,6 +327,16 @@ export interface ShopItem {
|
||||
preview: string; // emoji/avatar id/color
|
||||
}
|
||||
|
||||
/* ------------------------------ Coin packs --------------------------- */
|
||||
|
||||
export interface CoinPack {
|
||||
id: string;
|
||||
coins: number;
|
||||
bonus: number; // extra coins
|
||||
priceToman: number;
|
||||
tag?: "popular" | "best";
|
||||
}
|
||||
|
||||
/* --------------------------- Daily reward ---------------------------- */
|
||||
|
||||
export interface DailyRewardState {
|
||||
|
||||
+3
-2
@@ -12,18 +12,19 @@ export type Screen =
|
||||
| "matchmaking"
|
||||
| "leaderboard"
|
||||
| "shop"
|
||||
| "buycoins"
|
||||
| "chat"
|
||||
| "notifications"
|
||||
| "game"; // the table (used for both ai + online)
|
||||
|
||||
const ALL_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online",
|
||||
"room", "matchmaking", "leaderboard", "shop", "chat", "notifications", "game",
|
||||
"room", "matchmaking", "leaderboard", "shop", "buycoins", "chat", "notifications", "game",
|
||||
];
|
||||
|
||||
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||
export const STATIC_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "notifications",
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "buycoins", "notifications",
|
||||
];
|
||||
|
||||
export function screenFromHash(): Screen {
|
||||
|
||||
Reference in New Issue
Block a user