Zap·후원 FPI 귀속 · 이클립스 방어 Shake 전용 · 가입 팬덤만 방어
This commit is contained in:
@@ -9,7 +9,7 @@ import { callerUid, num, str } from '../core/callable.js';
|
||||
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||
import { FPI_AXES, loadPolicy, weightsAreUsable, type FpiAxis } from '../core/config.js';
|
||||
import { spendPop } from '../ledger/ledger.js';
|
||||
import { rankWithTies } from '../ledger/fpi.js';
|
||||
import { isMemberOf, rankWithTies } from '../ledger/fpi.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
@@ -106,6 +106,10 @@ export async function defendEclipse(input: {
|
||||
db.doc(`users/${uid}`).get(),
|
||||
loadEclipseSettings(),
|
||||
]);
|
||||
// 방어는 그 팬덤의 활동이다 — 가입하지 않은 행성의 이클립스는 막을 수 없다(10/3 회신 #10).
|
||||
if (!isMemberOf(user, fandomId)) {
|
||||
fail(ERR.permissionDenied, '가입한 팬덤의 이클립스에만 참여할 수 있습니다.');
|
||||
}
|
||||
|
||||
const idempotencyKey = `defend:${uid}:${eclipseId}:${clientSeq}`;
|
||||
const logRef = eclipseRef.collection('defenseLogs').doc(txIdOf(idempotencyKey));
|
||||
@@ -327,6 +331,8 @@ export const DEFAULT_ECLIPSE: EclipseSettings = {
|
||||
dauWindowDays: 7,
|
||||
windowSec: 60,
|
||||
qualifyShakes: 60,
|
||||
// 🟡 임시값 — 회신 대기(해석 확인 요청 2026-10-07 영역A Q-A10). 아래 세 값은 자료 근거가 없다
|
||||
// (9/16 회신 3절 4항 「센서 판정 수치는 근거 없이 추가 확정하지 않음」). `config/eclipse` 운영값이 덮는다.
|
||||
perCallMaxShakes: 50,
|
||||
perUserMaxDefense: 500,
|
||||
minIntervalMs: 300,
|
||||
|
||||
@@ -28,6 +28,13 @@ export function fandomOfUser(user: DocumentSnapshot | null | undefined): string
|
||||
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
|
||||
}
|
||||
|
||||
/** 사용자가 그 팬덤에 가입해 있는가 — `users.fandomIds`(가입 · 탈퇴 트랜잭션이 유지) 또는 대표 팬덤. */
|
||||
export function isMemberOf(user: DocumentSnapshot | null | undefined, fandomId: string): boolean {
|
||||
const joined = user?.get('fandomIds');
|
||||
return fandomId === fandomOfUser(user) || fandomId === user?.get('fandomId') ||
|
||||
(Array.isArray(joined) && joined.includes(fandomId));
|
||||
}
|
||||
|
||||
export interface FpiActivity {
|
||||
uid: string;
|
||||
fandomId: string | null;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
|
||||
import type { DocumentReference, DocumentSnapshot, Firestore, Transaction } from 'firebase-admin/firestore';
|
||||
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
import { lotIdOf, txIdOf } from '../core/ids.js';
|
||||
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
|
||||
import { fandomOfUser, voidCompletions, writeFpiEvent } from './fpi.js';
|
||||
import { fandomOfUser, isMemberOf, voidCompletions, writeFpiEvent } from './fpi.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
@@ -491,10 +491,13 @@ async function spendParts(
|
||||
}, { merge: true });
|
||||
});
|
||||
|
||||
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
|
||||
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
|
||||
// 캠페인 문서에서 정한 팬덤을 넘긴다.
|
||||
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
|
||||
// Zap 은 **Zap 한 행성**에 귀속한다(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」 · 10/3 회신 #10).
|
||||
// 남의 행성 성장을 올리지 못하게 대상이 **이 트랜잭션에서 읽은 가입 팬덤**인지 서버가
|
||||
// 확인한다. 대상이 없으면(구 클라이언트) 대표 팬덤. 후원은 호출자(fundCampaign)가 캠페인
|
||||
// 문서에서 정한 팬덤을 넘긴다 — 성장 · FPI 모두 그 팬덤이다.
|
||||
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION'
|
||||
? zapFandomOf(userSnap, input.fandomId)
|
||||
: input.fandomId;
|
||||
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
|
||||
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
|
||||
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
|
||||
@@ -519,7 +522,7 @@ async function spendParts(
|
||||
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
|
||||
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
|
||||
writeFpiEvent(tx, {
|
||||
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
|
||||
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
|
||||
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
|
||||
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
|
||||
sourceId, occurredAt: new Date(),
|
||||
@@ -541,6 +544,13 @@ async function spendParts(
|
||||
}
|
||||
}
|
||||
|
||||
/** Zap 대상 팬덤 — 가입 팬덤(`users.fandomIds` 또는 대표 팬덤)일 때만. 없으면 대표 팬덤. */
|
||||
function zapFandomOf(user: DocumentSnapshot | null, target: string | undefined): string | null {
|
||||
if (!target) return fandomOfUser(user);
|
||||
if (isMemberOf(user, target)) return target;
|
||||
fail(ERR.permissionDenied, '가입한 팬덤의 행성에만 Zap 할 수 있습니다.');
|
||||
}
|
||||
|
||||
type RefundReason = Extract<ReasonCode,
|
||||
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
|
||||
|
||||
|
||||
@@ -55,8 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
|
||||
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
|
||||
*
|
||||
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
|
||||
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤도 원장이 사용자
|
||||
* 문서에서 정한다 — 클라이언트 `fandomId` 는 받지 않는다(리뷰 M1).
|
||||
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
|
||||
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
|
||||
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
|
||||
*/
|
||||
export const fnPopSpend = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
@@ -79,6 +80,7 @@ export const fnPopSpend = onCall(async (req) => {
|
||||
// (`bounty:…` · `campaignFee:…`)를 먼저 차지해 그 거래를 막을 수 있다.
|
||||
idempotencyKey: `user:${uid}:${str(req.data, 'idempotencyKey')}`,
|
||||
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
|
||||
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
|
||||
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
|
||||
count,
|
||||
});
|
||||
|
||||
@@ -278,6 +278,16 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
||||
expect((await shardSum()).defense).toBe(5);
|
||||
});
|
||||
|
||||
it('가입하지 않은 팬덤의 이클립스는 막을 수 없다(10/3 #10)', async () => {
|
||||
await seed();
|
||||
await getFirestore().doc('users/test-eclipse-outsider').set({ nickname: '외부', fandomId: 'other' });
|
||||
await expect(defendEclipse({
|
||||
uid: 'test-eclipse-outsider', fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'o1', shakes: 5,
|
||||
})).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||
expect((await shardSum()).defense).toBe(0);
|
||||
await getFirestore().doc('users/test-eclipse-outsider').delete();
|
||||
});
|
||||
|
||||
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
|
||||
await seed({ minIntervalMs: 60_000 });
|
||||
await defendEclipse({
|
||||
|
||||
@@ -146,12 +146,16 @@ it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠
|
||||
expect(wallet.activityBalance).toBe(50);
|
||||
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(20);
|
||||
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
|
||||
// FPI 도 캠페인 팬덤 b 로 — 성장과 갈라지지 않는다(10/3 #10).
|
||||
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(1);
|
||||
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
|
||||
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
|
||||
const donations = await db.collection(`campaigns/${cid}/donations`).get();
|
||||
expect(donations.docs.map((d) => d.get('donorUid'))).toEqual([uid]);
|
||||
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
|
||||
});
|
||||
|
||||
it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하며 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
|
||||
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
|
||||
const db = getFirestore();
|
||||
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
||||
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
||||
@@ -164,14 +168,27 @@ it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하
|
||||
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
|
||||
|
||||
// 클라이언트가 count 1e9 · 남의 팬덤 b 를 보내도 서버는 30 ÷ 10 = 3 회 · 대표 팬덤 a 로 적는다.
|
||||
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
|
||||
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
|
||||
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||
|
||||
// b 에 가입한 뒤에는 **Zap 한 행성 b** 에 성장 · FPI · 기여가 귀속된다(상세정의 5장 · 10/3 #10).
|
||||
// 대표 팬덤은 a 그대로다. 클라이언트 count 1e9 는 무시하고 30 ÷ 10 = 3 회로 적는다.
|
||||
await db.doc(`users/${uid}`).update({ fandomIds: [a, b] });
|
||||
await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b,
|
||||
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' });
|
||||
const events = await db.collection(`fandoms/${a}/fpiEvents`).get();
|
||||
const events = await db.collection(`fandoms/${b}/fpiEvents`).get();
|
||||
expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]);
|
||||
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(0);
|
||||
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(30);
|
||||
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
|
||||
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
|
||||
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(30);
|
||||
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
|
||||
expect((await db.doc(`fandoms/${b}/members/${uid}`).get()).get('contributedPop')).toBe(30);
|
||||
|
||||
// 대상이 없는 구 클라이언트는 대표 팬덤 a 로 간다.
|
||||
await run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:a1' });
|
||||
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
|
||||
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
|
||||
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
|
||||
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/live_stream.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../quest/quest_evidence.dart' show deviceShakes;
|
||||
import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes;
|
||||
import 'eclipse_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
@@ -34,10 +34,14 @@ class EclipseDefendScreen extends StatefulWidget {
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
this.shakes = deviceShakes,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
|
||||
/// 흔들기 입력. 테스트는 가짜 스트림을 넣는다 — 방어 수단은 **Shake 하나**다(FX §5).
|
||||
final ShakeProbe shakes;
|
||||
|
||||
/// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다.
|
||||
final String fandomId;
|
||||
|
||||
@@ -67,6 +71,9 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
|
||||
Timer? _burst;
|
||||
bool _sending = false;
|
||||
|
||||
/// 가속도 센서가 없는 단말. 탭 대체 참여는 두지 않는다(FX §5 「수동 방어 Shake only」).
|
||||
bool _noSensor = false;
|
||||
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
|
||||
|
||||
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
|
||||
@@ -77,11 +84,12 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 흔들기 판정 규칙(문턱 · 표본 주기)은 Music Shake 와 같다 — [ShakeDetector].
|
||||
_accel = deviceShakes().listen(
|
||||
_accel = widget.shakes().listen(
|
||||
(_) => _shake(),
|
||||
onError: (_) {
|
||||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
|
||||
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
|
||||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 방어는 Shake only(FX §5)라
|
||||
// 대체 입력을 두지 않고 지원하지 않는다고만 알린다.
|
||||
if (mounted) setState(() => _noSensor = true);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -396,7 +404,6 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
final remaining = window.inMilliseconds <= 0
|
||||
? 0.0
|
||||
: (left.inMilliseconds / window.inMilliseconds).clamp(0.0, 1.0);
|
||||
final canShake = state.canShake(now);
|
||||
return Column(
|
||||
children: [
|
||||
const _CloseBar(),
|
||||
@@ -467,26 +474,8 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를 묻게 두지 않는다(설계서 v1.1 §9-#5).
|
||||
_Note(context.l10n.eclipseNoPopNote('${state.qualifyShakes}')),
|
||||
if (_batch != null) _retryButton(),
|
||||
// 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로 이 버튼이
|
||||
// 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는 보조 입력이라
|
||||
// 채움 없는 낮은 강조로 둔다.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s2,
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: canShake && (_batch == null || _sending) ? _shake : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicSurfaceContainer,
|
||||
foregroundColor: AppColors.onCosmicVariant,
|
||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||
),
|
||||
child: Text(context.l10n.eclipseTapToJoin),
|
||||
),
|
||||
),
|
||||
if (_noSensor) _Note(context.l10n.eclipseNoSensor),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"eclipseEnded": "The eclipse has already ended or was successfully defended.",
|
||||
"eclipseTooFast": "You're participating too fast. Please try again shortly.",
|
||||
"eclipseNone": "No eclipse in progress",
|
||||
"eclipseSuccessTitle": "You stopped\nthe meteor impact!",
|
||||
"eclipseSuccessTitle": "You stopped\nthe eclipse!",
|
||||
"eclipseQualified": "Participation confirmed",
|
||||
"eclipseFailTitle": "The planet defense\nfailed this time",
|
||||
"eclipseFailBody": "This round's defense goal wasn't reached.",
|
||||
@@ -187,13 +187,13 @@
|
||||
"eclipseCancelBody": "Cancelled due to a tally error. No FPI was deducted.",
|
||||
"eclipsePendingTitle": "Checking defense result",
|
||||
"eclipsePendingBody": "Participation time is over. Waiting for the server's decision.",
|
||||
"eclipseShakeTimed": "s — shake your phone\nto stop the meteor impact!",
|
||||
"eclipseShake": "Shake your phone\nto stop the meteor impact!",
|
||||
"eclipseShakeTimed": "s — shake your phone\nto stop the eclipse!",
|
||||
"eclipseShake": "Shake your phone\nto stop the eclipse!",
|
||||
"eclipseMyCount": "My shakes: {count}",
|
||||
"eclipsePendingSuffix": " · +{count} pending",
|
||||
"eclipseSendingSuffix": " · Sending",
|
||||
"eclipseNoPopNote": "No POP is spent. Make at least {count} valid shakes to count as a participant.",
|
||||
"eclipseTapToJoin": "Can't shake? Tap to join",
|
||||
"eclipseNoSensor": "This device doesn't support shake defense",
|
||||
"eclipseTimeLeft": "Eclipse time left:",
|
||||
"eclipseSeconds": " {seconds}s",
|
||||
"eclipseBannerDefended": "Defended — no FPI loss for {name}",
|
||||
@@ -800,11 +800,11 @@
|
||||
"homeGrowTogether": "Grow together",
|
||||
"homeArtistNews": "Artist News",
|
||||
"growthConditionsTitle": "Growth conditions",
|
||||
"eclipseButtonSoon": "Eclipse in {time}!\nTap the planet to stop it!",
|
||||
"eclipseButtonSoon": "Eclipse in {time}!\nShake your phone to stop it!",
|
||||
"eclipseButtonActive": "The eclipse has started!\nShake your phone within {seconds}s to stop it!",
|
||||
"eclipseSec": "{seconds}s",
|
||||
"eclipseMinSec": "{minutes}m {seconds}s",
|
||||
"eclipseOverlayTitle": "Meteor impact\nis starting!",
|
||||
"eclipseOverlayTitle": "An eclipse is\nstarting on the planet!",
|
||||
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
|
||||
"eclipseMyShakes": "My valid shakes {count} / {total}",
|
||||
"questGoalBpm": "Target BPM",
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"eclipseEnded": "El eclipse ya terminó o se defendió con éxito.",
|
||||
"eclipseTooFast": "Estás participando demasiado rápido. Inténtalo en un momento.",
|
||||
"eclipseNone": "No hay ningún eclipse en curso",
|
||||
"eclipseSuccessTitle": "¡Detuviste\nel impacto del meteorito!",
|
||||
"eclipseSuccessTitle": "¡Detuviste\nel eclipse!",
|
||||
"eclipseQualified": "Participación confirmada",
|
||||
"eclipseFailTitle": "Lamentablemente,\nla defensa del planeta falló",
|
||||
"eclipseFailBody": "No se alcanzó el objetivo de defensa de esta ronda.",
|
||||
@@ -187,13 +187,13 @@
|
||||
"eclipseCancelBody": "Se canceló por un error de recuento. No se descontó FPI.",
|
||||
"eclipsePendingTitle": "Comprobando resultado de la defensa",
|
||||
"eclipsePendingBody": "El tiempo de participación terminó. Esperando la decisión del servidor.",
|
||||
"eclipseShakeTimed": "s: ¡agita tu móvil\npara detener el meteorito!",
|
||||
"eclipseShake": "¡Agita tu móvil\npara detener el meteorito!",
|
||||
"eclipseShakeTimed": "s: ¡agita tu móvil\npara detener el eclipse!",
|
||||
"eclipseShake": "¡Agita tu móvil\npara detener el eclipse!",
|
||||
"eclipseMyCount": "Mis participaciones: {count}",
|
||||
"eclipsePendingSuffix": " · +{count} pendientes",
|
||||
"eclipseSendingSuffix": " · Enviando",
|
||||
"eclipseNoPopNote": "No se gasta POP. Haz al menos {count} sacudidas válidas para contar como participante.",
|
||||
"eclipseTapToJoin": "¿No puedes agitar? Toca para participar",
|
||||
"eclipseNoSensor": "Este dispositivo no admite la defensa con agitación",
|
||||
"eclipseTimeLeft": "Tiempo restante del eclipse:",
|
||||
"eclipseSeconds": " {seconds} s",
|
||||
"eclipseBannerDefended": "Defensa exitosa: {name} sin pérdida de FPI",
|
||||
@@ -800,11 +800,11 @@
|
||||
"homeGrowTogether": "Crecer juntos",
|
||||
"homeArtistNews": "Noticias del artista",
|
||||
"growthConditionsTitle": "Condiciones de crecimiento",
|
||||
"eclipseButtonSoon": "¡Eclipse en {time}!\n¡Toca el planeta para detenerlo!",
|
||||
"eclipseButtonSoon": "¡Eclipse en {time}!\n¡Agita tu móvil para detenerlo!",
|
||||
"eclipseButtonActive": "¡El eclipse ha comenzado!\n¡Agita el teléfono en {seconds} s para detenerlo!",
|
||||
"eclipseSec": "{seconds} s",
|
||||
"eclipseMinSec": "{minutes} min {seconds} s",
|
||||
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
|
||||
"eclipseOverlayTitle": "¡Un eclipse\nva a comenzar en el planeta!",
|
||||
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
|
||||
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}",
|
||||
"questGoalBpm": "BPM objetivo",
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"eclipseEnded": "イクリプスはすでに終了したか、防衛に成功しました。",
|
||||
"eclipseTooFast": "参加が速すぎます。しばらくしてから再度お試しください。",
|
||||
"eclipseNone": "進行中のイクリプスはありません",
|
||||
"eclipseSuccessTitle": "隕石の衝突を\n見事に防ぎました!",
|
||||
"eclipseSuccessTitle": "イクリプスを\n見事に防ぎました!",
|
||||
"eclipseQualified": "参加認定完了",
|
||||
"eclipseFailTitle": "惜しくも惑星の防衛に\n失敗しました",
|
||||
"eclipseFailBody": "今回の防衛目標を達成できませんでした。",
|
||||
@@ -187,13 +187,13 @@
|
||||
"eclipseCancelBody": "集計エラーのため中止されました。FPIは差し引かれていません。",
|
||||
"eclipsePendingTitle": "防衛結果を確認中",
|
||||
"eclipsePendingBody": "参加時間が終了しました。サーバーの判定を待っています。",
|
||||
"eclipseShakeTimed": "秒間スマホを振って\n隕石の衝突を防ごう!",
|
||||
"eclipseShake": "スマホを振って\n隕石の衝突を防ごう!",
|
||||
"eclipseShakeTimed": "秒間スマホを振って\nイクリプスを防ごう!",
|
||||
"eclipseShake": "スマホを振って\nイクリプスを防ごう!",
|
||||
"eclipseMyCount": "自分の参加 {count}回",
|
||||
"eclipsePendingSuffix": " · +{count} 送信待ち",
|
||||
"eclipseSendingSuffix": " · 送信中",
|
||||
"eclipseNoPopNote": "POPは消費されません。有効Shakeを{count}回以上振って防衛に参加すると参加者として認定されます。",
|
||||
"eclipseTapToJoin": "振るのが難しければタップで参加",
|
||||
"eclipseNoSensor": "この端末は振る防御に対応していません",
|
||||
"eclipseTimeLeft": "イクリプス残り時間:",
|
||||
"eclipseSeconds": " {seconds}秒",
|
||||
"eclipseBannerDefended": "防衛成功 — {name} FPI減少なし",
|
||||
@@ -800,11 +800,11 @@
|
||||
"homeGrowTogether": "一緒に成長する",
|
||||
"homeArtistNews": "アーティストニュース",
|
||||
"growthConditionsTitle": "成長条件",
|
||||
"eclipseButtonSoon": "{time}後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!",
|
||||
"eclipseButtonSoon": "{time}後にイクリプスが発生します!\nスマホを振ってイクリプスを防ぎましょう!",
|
||||
"eclipseButtonActive": "イクリプスが発生しました!\n{seconds}秒以内にスマホを振って防ぎましょう!",
|
||||
"eclipseSec": "{seconds}秒",
|
||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
|
||||
"eclipseOverlayTitle": "惑星にイクリプスが\n始まります!",
|
||||
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
|
||||
"eclipseMyShakes": "自分の有効Shake {count} / {total}回",
|
||||
"questGoalBpm": "目標BPM",
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
"eclipseEnded": "이클립스가 이미 끝났거나 방어에 성공했습니다.",
|
||||
"eclipseTooFast": "너무 빠르게 참여했습니다. 잠시 뒤 다시 시도해 주십시오.",
|
||||
"eclipseNone": "진행 중인 이클립스가 없습니다",
|
||||
"eclipseSuccessTitle": "운석 충돌을\n성공적으로 막았어요!",
|
||||
"eclipseSuccessTitle": "이클립스를\n성공적으로 막았어요!",
|
||||
"eclipseQualified": "참여 인정 완료",
|
||||
"eclipseFailTitle": "아쉽게 행성 방어를\n실패했어요",
|
||||
"eclipseFailBody": "이번 회차의 방어 목표를 달성하지 못했습니다.",
|
||||
@@ -336,8 +336,8 @@
|
||||
"eclipseCancelBody": "집계 오류로 취소되었습니다. FPI는 차감되지 않았습니다.",
|
||||
"eclipsePendingTitle": "방어 결과 확인 중",
|
||||
"eclipsePendingBody": "참여 시간이 끝났습니다. 서버 판정을 기다리고 있습니다.",
|
||||
"eclipseShakeTimed": "초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!",
|
||||
"eclipseShake": "휴대폰을 흔들어\n행성 운석 충돌을 막으세요!",
|
||||
"eclipseShakeTimed": "초간 휴대폰을 흔들어\n이클립스를 막으세요!",
|
||||
"eclipseShake": "휴대폰을 흔들어\n이클립스를 막으세요!",
|
||||
"eclipseMyCount": "내 참여 {count}회",
|
||||
"@eclipseMyCount": {
|
||||
"placeholders": {
|
||||
@@ -363,7 +363,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"eclipseTapToJoin": "흔들기 어려우면 눌러서 참여",
|
||||
"eclipseNoSensor": "이 기기는 흔들기 방어를 지원하지 않아요",
|
||||
"eclipseTimeLeft": "이클립스 발생 남은 시간 :",
|
||||
"eclipseSeconds": " {seconds}초",
|
||||
"@eclipseSeconds": {
|
||||
@@ -1654,7 +1654,7 @@
|
||||
"homeGrowTogether": "함께 성장하기",
|
||||
"homeArtistNews": "아티스트 소식",
|
||||
"growthConditionsTitle": "성장 조건",
|
||||
"eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!",
|
||||
"eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!",
|
||||
"@eclipseButtonSoon": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
@@ -1689,7 +1689,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"eclipseOverlayTitle": "행성 운석 충돌이\n시작됩니다!",
|
||||
"eclipseOverlayTitle": "행성에 이클립스가\n시작됩니다!",
|
||||
"eclipseLiveOccurred": "{name} 행성에 이클립스가 발생했어요!",
|
||||
"@eclipseLiveOccurred": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -1181,7 +1181,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @eclipseSuccessTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'운석 충돌을\n성공적으로 막았어요!'**
|
||||
/// **'이클립스를\n성공적으로 막았어요!'**
|
||||
String get eclipseSuccessTitle;
|
||||
|
||||
/// No description provided for @eclipseQualified.
|
||||
@@ -1229,13 +1229,13 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @eclipseShakeTimed.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'**
|
||||
/// **'초간 휴대폰을 흔들어\n이클립스를 막으세요!'**
|
||||
String get eclipseShakeTimed;
|
||||
|
||||
/// No description provided for @eclipseShake.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'**
|
||||
/// **'휴대폰을 흔들어\n이클립스를 막으세요!'**
|
||||
String get eclipseShake;
|
||||
|
||||
/// No description provided for @eclipseMyCount.
|
||||
@@ -1262,11 +1262,11 @@ abstract class AppLocalizations {
|
||||
/// **'POP은 소모되지 않습니다. 유효 Shake {count}회 이상 흔들어 방어에 참여하면 참여자로 인정됩니다.'**
|
||||
String eclipseNoPopNote(String count);
|
||||
|
||||
/// No description provided for @eclipseTapToJoin.
|
||||
/// No description provided for @eclipseNoSensor.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'흔들기 어려우면 눌러서 참여'**
|
||||
String get eclipseTapToJoin;
|
||||
/// **'이 기기는 흔들기 방어를 지원하지 않아요'**
|
||||
String get eclipseNoSensor;
|
||||
|
||||
/// No description provided for @eclipseTimeLeft.
|
||||
///
|
||||
@@ -4919,7 +4919,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @eclipseButtonSoon.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!'**
|
||||
/// **'{time} 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!'**
|
||||
String eclipseButtonSoon(String time);
|
||||
|
||||
/// No description provided for @eclipseButtonActive.
|
||||
@@ -4943,7 +4943,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @eclipseOverlayTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'행성 운석 충돌이\n시작됩니다!'**
|
||||
/// **'행성에 이클립스가\n시작됩니다!'**
|
||||
String get eclipseOverlayTitle;
|
||||
|
||||
/// No description provided for @eclipseLiveOccurred.
|
||||
|
||||
@@ -642,7 +642,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get eclipseNone => 'No eclipse in progress';
|
||||
|
||||
@override
|
||||
String get eclipseSuccessTitle => 'You stopped\nthe meteor impact!';
|
||||
String get eclipseSuccessTitle => 'You stopped\nthe eclipse!';
|
||||
|
||||
@override
|
||||
String get eclipseQualified => 'Participation confirmed';
|
||||
@@ -668,11 +668,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Participation time is over. Waiting for the server\'s decision.';
|
||||
|
||||
@override
|
||||
String get eclipseShakeTimed =>
|
||||
's — shake your phone\nto stop the meteor impact!';
|
||||
String get eclipseShakeTimed => 's — shake your phone\nto stop the eclipse!';
|
||||
|
||||
@override
|
||||
String get eclipseShake => 'Shake your phone\nto stop the meteor impact!';
|
||||
String get eclipseShake => 'Shake your phone\nto stop the eclipse!';
|
||||
|
||||
@override
|
||||
String eclipseMyCount(String count) {
|
||||
@@ -693,7 +692,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => 'Can\'t shake? Tap to join';
|
||||
String get eclipseNoSensor => 'This device doesn\'t support shake defense';
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => 'Eclipse time left:';
|
||||
@@ -2791,7 +2790,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String eclipseButtonSoon(String time) {
|
||||
return 'Eclipse in $time!\nTap the planet to stop it!';
|
||||
return 'Eclipse in $time!\nShake your phone to stop it!';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2810,7 +2809,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseOverlayTitle => 'Meteor impact\nis starting!';
|
||||
String get eclipseOverlayTitle => 'An eclipse is\nstarting on the planet!';
|
||||
|
||||
@override
|
||||
String eclipseLiveOccurred(String name) {
|
||||
|
||||
@@ -648,7 +648,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get eclipseNone => 'No hay ningún eclipse en curso';
|
||||
|
||||
@override
|
||||
String get eclipseSuccessTitle => '¡Detuviste\nel impacto del meteorito!';
|
||||
String get eclipseSuccessTitle => '¡Detuviste\nel eclipse!';
|
||||
|
||||
@override
|
||||
String get eclipseQualified => 'Participación confirmada';
|
||||
@@ -677,10 +677,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get eclipseShakeTimed =>
|
||||
's: ¡agita tu móvil\npara detener el meteorito!';
|
||||
's: ¡agita tu móvil\npara detener el eclipse!';
|
||||
|
||||
@override
|
||||
String get eclipseShake => '¡Agita tu móvil\npara detener el meteorito!';
|
||||
String get eclipseShake => '¡Agita tu móvil\npara detener el eclipse!';
|
||||
|
||||
@override
|
||||
String eclipseMyCount(String count) {
|
||||
@@ -701,7 +701,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '¿No puedes agitar? Toca para participar';
|
||||
String get eclipseNoSensor =>
|
||||
'Este dispositivo no admite la defensa con agitación';
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => 'Tiempo restante del eclipse:';
|
||||
@@ -2803,7 +2804,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String eclipseButtonSoon(String time) {
|
||||
return '¡Eclipse en $time!\n¡Toca el planeta para detenerlo!';
|
||||
return '¡Eclipse en $time!\n¡Agita tu móvil para detenerlo!';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2822,7 +2823,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseOverlayTitle => '¡El impacto de meteoritos\nva a comenzar!';
|
||||
String get eclipseOverlayTitle => '¡Un eclipse\nva a comenzar en el planeta!';
|
||||
|
||||
@override
|
||||
String eclipseLiveOccurred(String name) {
|
||||
|
||||
@@ -614,7 +614,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get eclipseNone => '進行中のイクリプスはありません';
|
||||
|
||||
@override
|
||||
String get eclipseSuccessTitle => '隕石の衝突を\n見事に防ぎました!';
|
||||
String get eclipseSuccessTitle => 'イクリプスを\n見事に防ぎました!';
|
||||
|
||||
@override
|
||||
String get eclipseQualified => '参加認定完了';
|
||||
@@ -638,10 +638,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get eclipsePendingBody => '参加時間が終了しました。サーバーの判定を待っています。';
|
||||
|
||||
@override
|
||||
String get eclipseShakeTimed => '秒間スマホを振って\n隕石の衝突を防ごう!';
|
||||
String get eclipseShakeTimed => '秒間スマホを振って\nイクリプスを防ごう!';
|
||||
|
||||
@override
|
||||
String get eclipseShake => 'スマホを振って\n隕石の衝突を防ごう!';
|
||||
String get eclipseShake => 'スマホを振って\nイクリプスを防ごう!';
|
||||
|
||||
@override
|
||||
String eclipseMyCount(String count) {
|
||||
@@ -662,7 +662,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '振るのが難しければタップで参加';
|
||||
String get eclipseNoSensor => 'この端末は振る防御に対応していません';
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => 'イクリプス残り時間:';
|
||||
@@ -2698,7 +2698,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String eclipseButtonSoon(String time) {
|
||||
return '$time後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!';
|
||||
return '$time後にイクリプスが発生します!\nスマホを振ってイクリプスを防ぎましょう!';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2717,7 +2717,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseOverlayTitle => '惑星への隕石衝突が\n始まります!';
|
||||
String get eclipseOverlayTitle => '惑星にイクリプスが\n始まります!';
|
||||
|
||||
@override
|
||||
String eclipseLiveOccurred(String name) {
|
||||
|
||||
@@ -614,7 +614,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get eclipseNone => '진행 중인 이클립스가 없습니다';
|
||||
|
||||
@override
|
||||
String get eclipseSuccessTitle => '운석 충돌을\n성공적으로 막았어요!';
|
||||
String get eclipseSuccessTitle => '이클립스를\n성공적으로 막았어요!';
|
||||
|
||||
@override
|
||||
String get eclipseQualified => '참여 인정 완료';
|
||||
@@ -638,10 +638,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get eclipsePendingBody => '참여 시간이 끝났습니다. 서버 판정을 기다리고 있습니다.';
|
||||
|
||||
@override
|
||||
String get eclipseShakeTimed => '초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!';
|
||||
String get eclipseShakeTimed => '초간 휴대폰을 흔들어\n이클립스를 막으세요!';
|
||||
|
||||
@override
|
||||
String get eclipseShake => '휴대폰을 흔들어\n행성 운석 충돌을 막으세요!';
|
||||
String get eclipseShake => '휴대폰을 흔들어\n이클립스를 막으세요!';
|
||||
|
||||
@override
|
||||
String eclipseMyCount(String count) {
|
||||
@@ -662,7 +662,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '흔들기 어려우면 눌러서 참여';
|
||||
String get eclipseNoSensor => '이 기기는 흔들기 방어를 지원하지 않아요';
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => '이클립스 발생 남은 시간 :';
|
||||
@@ -2700,7 +2700,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String eclipseButtonSoon(String time) {
|
||||
return '$time 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!';
|
||||
return '$time 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2719,7 +2719,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseOverlayTitle => '행성 운석 충돌이\n시작됩니다!';
|
||||
String get eclipseOverlayTitle => '행성에 이클립스가\n시작됩니다!';
|
||||
|
||||
@override
|
||||
String eclipseLiveOccurred(String name) {
|
||||
|
||||
@@ -614,7 +614,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get eclipseNone => '当前没有进行中的日蚀';
|
||||
|
||||
@override
|
||||
String get eclipseSuccessTitle => '成功阻止了\n陨石撞击!';
|
||||
String get eclipseSuccessTitle => '成功阻止了\n日食!';
|
||||
|
||||
@override
|
||||
String get eclipseQualified => '参与已认定';
|
||||
@@ -638,10 +638,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get eclipsePendingBody => '参与时间已结束,正在等待服务器判定。';
|
||||
|
||||
@override
|
||||
String get eclipseShakeTimed => '秒内摇动手机\n阻止陨石撞击星球!';
|
||||
String get eclipseShakeTimed => '秒内摇动手机\n阻止日食!';
|
||||
|
||||
@override
|
||||
String get eclipseShake => '摇动手机\n阻止陨石撞击星球!';
|
||||
String get eclipseShake => '摇动手机\n阻止日食!';
|
||||
|
||||
@override
|
||||
String eclipseMyCount(String count) {
|
||||
@@ -662,7 +662,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '不方便摇动?点击参与';
|
||||
String get eclipseNoSensor => '此设备不支持摇动防御';
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => '日蚀剩余时间:';
|
||||
@@ -2695,7 +2695,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String eclipseButtonSoon(String time) {
|
||||
return '$time后将发生日食!\n点击行星来阻止日食!';
|
||||
return '$time后将发生日食!\n摇动手机来阻止日食!';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2714,7 +2714,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get eclipseOverlayTitle => '陨石即将\n撞击行星!';
|
||||
String get eclipseOverlayTitle => '日食即将\n降临行星!';
|
||||
|
||||
@override
|
||||
String eclipseLiveOccurred(String name) {
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"eclipseEnded": "日蚀已结束或已防御成功。",
|
||||
"eclipseTooFast": "参与过快,请稍后再试。",
|
||||
"eclipseNone": "当前没有进行中的日蚀",
|
||||
"eclipseSuccessTitle": "成功阻止了\n陨石撞击!",
|
||||
"eclipseSuccessTitle": "成功阻止了\n日食!",
|
||||
"eclipseQualified": "参与已认定",
|
||||
"eclipseFailTitle": "很遗憾,\n星球防御失败了",
|
||||
"eclipseFailBody": "未达成本轮防御目标。",
|
||||
@@ -187,13 +187,13 @@
|
||||
"eclipseCancelBody": "因统计错误已取消,FPI 未扣除。",
|
||||
"eclipsePendingTitle": "正在确认防御结果",
|
||||
"eclipsePendingBody": "参与时间已结束,正在等待服务器判定。",
|
||||
"eclipseShakeTimed": "秒内摇动手机\n阻止陨石撞击星球!",
|
||||
"eclipseShake": "摇动手机\n阻止陨石撞击星球!",
|
||||
"eclipseShakeTimed": "秒内摇动手机\n阻止日食!",
|
||||
"eclipseShake": "摇动手机\n阻止日食!",
|
||||
"eclipseMyCount": "我的参与 {count} 次",
|
||||
"eclipsePendingSuffix": " · +{count} 待发送",
|
||||
"eclipseSendingSuffix": " · 发送中",
|
||||
"eclipseNoPopNote": "不会消耗 POP。有效摇动 {count} 次以上即可认定为参与者。",
|
||||
"eclipseTapToJoin": "不方便摇动?点击参与",
|
||||
"eclipseNoSensor": "此设备不支持摇动防御",
|
||||
"eclipseTimeLeft": "日蚀剩余时间:",
|
||||
"eclipseSeconds": " {seconds} 秒",
|
||||
"eclipseBannerDefended": "防御成功 — {name} FPI 未减少",
|
||||
@@ -800,11 +800,11 @@
|
||||
"homeGrowTogether": "一起成长",
|
||||
"homeArtistNews": "艺人动态",
|
||||
"growthConditionsTitle": "成长条件",
|
||||
"eclipseButtonSoon": "{time}后将发生日食!\n点击行星来阻止日食!",
|
||||
"eclipseButtonSoon": "{time}后将发生日食!\n摇动手机来阻止日食!",
|
||||
"eclipseButtonActive": "日食已发生!\n请在{seconds}秒内摇动手机来阻止!",
|
||||
"eclipseSec": "{seconds}秒",
|
||||
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
|
||||
"eclipseOverlayTitle": "日食即将\n降临行星!",
|
||||
"eclipseLiveOccurred": "{name}行星发生了日食!",
|
||||
"eclipseMyShakes": "我的有效摇动 {count} / {total}次",
|
||||
"questGoalBpm": "目标 BPM",
|
||||
|
||||
@@ -604,6 +604,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String eclipseNoPopNote(String count) => _fill('eclipseNoPopNote', {'count': count}) ?? _base.eclipseNoPopNote(count);
|
||||
|
||||
@override
|
||||
String get eclipseNoSensor => _remote['eclipseNoSensor'] ?? _base.eclipseNoSensor;
|
||||
|
||||
@override
|
||||
String get eclipseNone => _remote['eclipseNone'] ?? _base.eclipseNone;
|
||||
|
||||
@@ -646,9 +649,6 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle;
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => _remote['eclipseTapToJoin'] ?? _base.eclipseTapToJoin;
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart' show SensorInterval;
|
||||
@@ -24,6 +26,9 @@ Future<void> _pump(WidgetTester tester, Widget home) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
|
||||
final _shakes = StreamController<DateTime>.broadcast();
|
||||
|
||||
void main() {
|
||||
group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () {
|
||||
test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () {
|
||||
@@ -147,10 +152,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
addTearDown(repo.dispose);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||
expect(find.text('휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'), findsOneWidget);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
|
||||
expect(find.text('휴대폰을 흔들어\n이클립스를 막으세요!'), findsOneWidget);
|
||||
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
|
||||
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
|
||||
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async {
|
||||
@@ -162,8 +167,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
addTearDown(repo.dispose);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||
expect(find.text('행성 운석 충돌이\n시작됩니다!'), findsOneWidget);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
|
||||
expect(find.text('행성에 이클립스가\n시작됩니다!'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -177,12 +182,12 @@ void main() {
|
||||
),
|
||||
);
|
||||
addTearDown(repo.dispose);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
|
||||
expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget);
|
||||
expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget);
|
||||
expect(find.text('유효 참여 3 / 10명'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
_shakes.add(DateTime.now());
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
@@ -196,8 +201,8 @@ void main() {
|
||||
state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt),
|
||||
);
|
||||
addTearDown(repo.dispose);
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
|
||||
_shakes.add(DateTime.now());
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Future<void> _pumpGalaxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// 흔들어 방어 화면. 센서가 없는 테스트 환경이라 눌러서 참여하는 경로로 본다.
|
||||
/// 흔들어 방어 화면. 센서 대신 가짜 흔들기 스트림을 넣는다 — 방어는 Shake only(FX §5).
|
||||
Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2;
|
||||
@@ -64,7 +64,7 @@ Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: EclipseDefendScreen(repository: repo, fandomId: 'bts'),
|
||||
home: EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
@@ -77,6 +77,9 @@ Future<void> _settle(WidgetTester tester) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
|
||||
final _shakes = StreamController<DateTime>.broadcast();
|
||||
|
||||
void main() {
|
||||
group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () {
|
||||
testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async {
|
||||
@@ -367,14 +370,13 @@ void main() {
|
||||
expect(find.textContaining('흔들어 방어'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('누르면 흔들기가 묶여 서버로 나간다', (tester) async {
|
||||
testWidgets('흔들기가 묶여 서버로 나간다', (tester) async {
|
||||
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||
addTearDown(repo.dispose);
|
||||
await _pumpDefend(tester, repo);
|
||||
|
||||
// 센서가 없는 단말용 버튼. 판정은 서버가 하므로 흔들기와 같은 경로다.
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
for (var i = 0; i < 4; i++) {
|
||||
_shakes.add(DateTime.now());
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
}
|
||||
await _settle(tester);
|
||||
@@ -392,7 +394,7 @@ void main() {
|
||||
|
||||
// 다섯 번 참여했는데 서버가 둘만 받아들인 상황.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
_shakes.add(DateTime.now());
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
}
|
||||
await _settle(tester);
|
||||
@@ -406,15 +408,15 @@ void main() {
|
||||
addTearDown(repo.dispose);
|
||||
await _pumpDefend(tester, repo);
|
||||
|
||||
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
|
||||
expect(find.text('운석 충돌을\n성공적으로 막았어요!'), findsOneWidget);
|
||||
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
|
||||
expect(find.text('이클립스를\n성공적으로 막았어요!'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async {
|
||||
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||
addTearDown(repo.dispose);
|
||||
await _pumpDefend(tester, repo);
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
_shakes.add(DateTime.now());
|
||||
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
|
||||
await tester.pump();
|
||||
await _settle(tester);
|
||||
@@ -437,7 +439,7 @@ void main() {
|
||||
addTearDown(repo.dispose);
|
||||
await _pumpDefend(tester, repo);
|
||||
for (var i = 0; i < 12; i++) {
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
_shakes.add(DateTime.now());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(repo.calls, isNotEmpty);
|
||||
@@ -451,7 +453,7 @@ void main() {
|
||||
..failWith = const BackendException('unavailable', 'private backend error');
|
||||
addTearDown(repo.dispose);
|
||||
await _pumpDefend(tester, repo);
|
||||
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||
_shakes.add(DateTime.now());
|
||||
await _settle(tester);
|
||||
expect(repo.calls, hasLength(1));
|
||||
final original = repo.calls.single;
|
||||
@@ -465,6 +467,20 @@ void main() {
|
||||
expect(find.text('같은 참여 다시 확인'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('센서가 없는 단말은 탭 대체 없이 미지원 안내만 (FX §5 Shake only)', (tester) async {
|
||||
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||
addTearDown(repo.dispose);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: EclipseDefendScreen(repository: repo, fandomId: 'bts',
|
||||
shakes: () => Stream<DateTime>.error(StateError('no sensor'))),
|
||||
));
|
||||
await _settle(tester);
|
||||
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsOneWidget);
|
||||
expect(find.byType(FilledButton), findsNothing);
|
||||
expect(repo.calls, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('진행 중인 이클립스가 없으면 그 사실을 알린다', (tester) async {
|
||||
final repo = FakeEclipseRepository();
|
||||
addTearDown(repo.dispose);
|
||||
|
||||
Reference in New Issue
Block a user