From 427c67acc2740ba98f17ff318c7bbdce59d82590 Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:20:51 +0000 Subject: [PATCH 1/6] release: integrate V27 inbox batch --- VeilLink/App/VeilLinkApp.swift | 21 + VeilLink/ArcadeRealtime/PlatformerView.swift | 30 +- VeilLink/Core/LatticeClashGame.swift | 326 ++++++ VeilLink/Core/VeilAetherLabEngine.swift | 153 +++ VeilLink/Core/VeilAetherLabFamiliesA.swift | 224 ++++ VeilLink/Core/VeilAetherLabFamiliesB.swift | 241 ++++ VeilLink/Core/VeilAppleMaterialEngine.swift | 2 + VeilLink/Core/VeilEmotionGardenEngine.swift | 299 +++++ VeilLink/Core/VeilFeatureRegistry.swift | 269 +++++ VeilLink/Core/VeilHTMLImportPolicy.swift | 95 ++ VeilLink/Core/VeilMissionControlEngine.swift | 360 ++++++ VeilLink/Core/VeilOfflineHTMLImporter.swift | 70 ++ VeilLink/Core/VeilPairCameraInputGate.swift | 21 + VeilLink/Core/VeilProductivityEngine.swift | 283 +++++ VeilLink/Core/VeilRareLabEngine.swift | 260 +++++ VeilLink/Diagnostics/DeepTelemetry.swift | 44 +- .../DeviceStressTestController.swift | 22 +- .../RuntimeDiagnosticsBridge.swift | 3 + .../Diagnostics/VeilPreConsentBlackBox.swift | 12 +- .../Rendering/VeilFramePacer.swift | 58 + .../Rendering/VeilRenderFrameStats.swift | 61 + .../Rendering/VeilRenderGeometry.swift | 66 ++ .../Rendering/VeilRhythmNativeScene.swift | 135 +++ .../VeilRhythmR6NativeGameView.swift | 112 ++ .../Rendering/VeilRhythmRenderCache.swift | 72 ++ .../Rendering/VeilRhythmVisualTimeline.swift | 35 + .../VeilEntertainmentFeatureGate.swift | 55 + .../VeilRhythmExpeditionEngine.swift | 128 +++ .../VeilRhythmLiveConductor.swift | 123 ++ .../VeilStationChronicleEngine.swift | 194 ++++ VeilLink/Entertainment/VeilStoryEngine.swift | 255 +++++ VeilLink/Entertainment/VeilStoryLabView.swift | 100 ++ VeilLink/Resources/Info.plist | 2 +- VeilLink/Storage/BackupManager.swift | 2 + .../Storage/VeilBackupArchiveInspector.swift | 106 ++ .../Storage/VeilBackupPreflightPolicy.swift | 149 +++ VeilLink/UI/AfterglowConvoyLabView.swift | 22 +- VeilLink/UI/AshHarborLabView.swift | 11 + VeilLink/UI/BlackoutDistrictLabView.swift | 11 + VeilLink/UI/LatticeClashLabView.swift | 371 ++++++ VeilLink/UI/MiniGameViews.swift | 96 +- VeilLink/UI/RemotePairingView.swift | 69 +- VeilLink/UI/SignalDiveLabView.swift | 11 + .../TacticalV2/TacticalLandscapeShellV2.swift | 4 +- VeilLink/UI/ToolCenterView.swift | 24 +- VeilLink/UI/VeilAdaptiveParticleLayer.swift | 2 +- VeilLink/UI/VeilAetherLabView.swift | 124 ++ VeilLink/UI/VeilBackupPreflightView.swift | 86 ++ VeilLink/UI/VeilEmotionGardenView.swift | 373 ++++++ VeilLink/UI/VeilFeatureLibraryView.swift | 200 ++++ VeilLink/UI/VeilMissionControlView.swift | 102 ++ VeilLink/UI/VeilOfflineHTMLReaderView.swift | 133 +++ VeilLink/UI/VeilOfflineQRReaderView.swift | 111 ++ VeilLink/UI/VeilProductivityLabView.swift | 321 ++++++ VeilLink/UI/VeilRareLabView.swift | 103 ++ VeilLink/UI/VeilReleaseActivationView.swift | 17 +- VeilLink/UI/VeilV27FeatureHubView.swift | 102 ++ VeilLinkTests/LatticeClashGameTests.swift | 125 ++ VeilLinkTests/VeilAetherLabEngineTests.swift | 83 ++ .../VeilBackupArchiveInspectorTests.swift | 77 ++ .../VeilBackupPreflightPolicyTests.swift | 116 ++ .../VeilEmotionGardenEngineTests.swift | 71 ++ .../VeilEntertainmentFeatureGateTests.swift | 43 + ...EntertainmentR5IntegrationSmokeTests.swift | 50 + VeilLinkTests/VeilFeatureRegistryTests.swift | 130 +++ VeilLinkTests/VeilHTMLImportPolicyTests.swift | 41 + .../VeilMissionControlEngineTests.swift | 81 ++ .../VeilOfflineHTMLImporterTests.swift | 43 + .../VeilPairCameraInputGateTests.swift | 30 + .../VeilProductivityEngineTests.swift | 106 ++ VeilLinkTests/VeilR6FramePacerTests.swift | 57 + VeilLinkTests/VeilR6RenderGeometryTests.swift | 67 ++ VeilLinkTests/VeilR6VisualTimelineTests.swift | 46 + VeilLinkTests/VeilR7FrameStatsTests.swift | 68 ++ VeilLinkTests/VeilR7RenderCacheTests.swift | 70 ++ VeilLinkTests/VeilRareLabEngineTests.swift | 52 + VeilLinkTests/VeilRhythmExpeditionTests.swift | 108 ++ .../VeilRhythmLiveConductorTests.swift | 143 +++ .../VeilRhythmNativeSceneSmokeTests.swift | 26 + VeilLinkTests/VeilStationChronicleTests.swift | 138 +++ VeilLinkTests/VeilStoryEngineTests.swift | 93 ++ docs/RELEASE_27_0_BUILD_62.md | 31 + docs/THIRD_PARTY_NOTICES_V27.md | 11 + docs/V27_INBOX_INTEGRATION_MATRIX.md | 40 + .../VEILLINK_EMOTION_VALUE_1000_V1.md | 1015 +++++++++++++++++ project.yml | 12 +- scripts/ci-standard-preflight.py | 4 +- scripts/verify-core-release.py | 4 +- scripts/verify-maintenance-r8.py | 2 +- scripts/verify-runtime-maintenance-r12.py | 4 +- scripts/verify-ui-theme-r9.py | 2 +- scripts/verify-voice-ptt-r10.py | 4 +- 92 files changed, 9597 insertions(+), 77 deletions(-) create mode 100644 VeilLink/Core/LatticeClashGame.swift create mode 100644 VeilLink/Core/VeilAetherLabEngine.swift create mode 100644 VeilLink/Core/VeilAetherLabFamiliesA.swift create mode 100644 VeilLink/Core/VeilAetherLabFamiliesB.swift create mode 100644 VeilLink/Core/VeilEmotionGardenEngine.swift create mode 100644 VeilLink/Core/VeilFeatureRegistry.swift create mode 100644 VeilLink/Core/VeilHTMLImportPolicy.swift create mode 100644 VeilLink/Core/VeilMissionControlEngine.swift create mode 100644 VeilLink/Core/VeilOfflineHTMLImporter.swift create mode 100644 VeilLink/Core/VeilPairCameraInputGate.swift create mode 100644 VeilLink/Core/VeilProductivityEngine.swift create mode 100644 VeilLink/Core/VeilRareLabEngine.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilFramePacer.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRenderFrameStats.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRenderGeometry.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRhythmNativeScene.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRhythmR6NativeGameView.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRhythmRenderCache.swift create mode 100644 VeilLink/Entertainment/Rendering/VeilRhythmVisualTimeline.swift create mode 100644 VeilLink/Entertainment/VeilEntertainmentFeatureGate.swift create mode 100644 VeilLink/Entertainment/VeilRhythmExpeditionEngine.swift create mode 100644 VeilLink/Entertainment/VeilRhythmLiveConductor.swift create mode 100644 VeilLink/Entertainment/VeilStationChronicleEngine.swift create mode 100644 VeilLink/Entertainment/VeilStoryEngine.swift create mode 100644 VeilLink/Entertainment/VeilStoryLabView.swift create mode 100644 VeilLink/Storage/VeilBackupArchiveInspector.swift create mode 100644 VeilLink/Storage/VeilBackupPreflightPolicy.swift create mode 100644 VeilLink/UI/LatticeClashLabView.swift create mode 100644 VeilLink/UI/VeilAetherLabView.swift create mode 100644 VeilLink/UI/VeilBackupPreflightView.swift create mode 100644 VeilLink/UI/VeilEmotionGardenView.swift create mode 100644 VeilLink/UI/VeilFeatureLibraryView.swift create mode 100644 VeilLink/UI/VeilMissionControlView.swift create mode 100644 VeilLink/UI/VeilOfflineHTMLReaderView.swift create mode 100644 VeilLink/UI/VeilOfflineQRReaderView.swift create mode 100644 VeilLink/UI/VeilProductivityLabView.swift create mode 100644 VeilLink/UI/VeilRareLabView.swift create mode 100644 VeilLink/UI/VeilV27FeatureHubView.swift create mode 100644 VeilLinkTests/LatticeClashGameTests.swift create mode 100644 VeilLinkTests/VeilAetherLabEngineTests.swift create mode 100644 VeilLinkTests/VeilBackupArchiveInspectorTests.swift create mode 100644 VeilLinkTests/VeilBackupPreflightPolicyTests.swift create mode 100644 VeilLinkTests/VeilEmotionGardenEngineTests.swift create mode 100644 VeilLinkTests/VeilEntertainmentFeatureGateTests.swift create mode 100644 VeilLinkTests/VeilEntertainmentR5IntegrationSmokeTests.swift create mode 100644 VeilLinkTests/VeilFeatureRegistryTests.swift create mode 100644 VeilLinkTests/VeilHTMLImportPolicyTests.swift create mode 100644 VeilLinkTests/VeilMissionControlEngineTests.swift create mode 100644 VeilLinkTests/VeilOfflineHTMLImporterTests.swift create mode 100644 VeilLinkTests/VeilPairCameraInputGateTests.swift create mode 100644 VeilLinkTests/VeilProductivityEngineTests.swift create mode 100644 VeilLinkTests/VeilR6FramePacerTests.swift create mode 100644 VeilLinkTests/VeilR6RenderGeometryTests.swift create mode 100644 VeilLinkTests/VeilR6VisualTimelineTests.swift create mode 100644 VeilLinkTests/VeilR7FrameStatsTests.swift create mode 100644 VeilLinkTests/VeilR7RenderCacheTests.swift create mode 100644 VeilLinkTests/VeilRareLabEngineTests.swift create mode 100644 VeilLinkTests/VeilRhythmExpeditionTests.swift create mode 100644 VeilLinkTests/VeilRhythmLiveConductorTests.swift create mode 100644 VeilLinkTests/VeilRhythmNativeSceneSmokeTests.swift create mode 100644 VeilLinkTests/VeilStationChronicleTests.swift create mode 100644 VeilLinkTests/VeilStoryEngineTests.swift create mode 100644 docs/RELEASE_27_0_BUILD_62.md create mode 100644 docs/THIRD_PARTY_NOTICES_V27.md create mode 100644 docs/V27_INBOX_INTEGRATION_MATRIX.md create mode 100644 docs/features/VEILLINK_EMOTION_VALUE_1000_V1.md diff --git a/VeilLink/App/VeilLinkApp.swift b/VeilLink/App/VeilLinkApp.swift index 532b677e..49b329e1 100644 --- a/VeilLink/App/VeilLinkApp.swift +++ b/VeilLink/App/VeilLinkApp.swift @@ -274,6 +274,25 @@ private final class AppBootstrap: ObservableObject { } catch { model = nil errorMessage = error.localizedDescription + // Fixed-event diagnostics remain exportable without AppModel, SQLite or Keychain. + VeilPreConsentBlackBox.shared.record(.startupFailed, stage: .startupFailure) + // Fixed categories only: never copy an error string, identifier or key. + let failureCategory: VeilPreConsentBlackBox.Event + if let databaseError = error as? DatabaseError { + switch databaseError { + case .encryptionKeyMissing: + failureCategory = .startupKeyMissing + case .encryptionKeyTemporarilyUnavailable(_): + failureCategory = .startupKeyUnavailable + case .encryptionKeyMismatch: + failureCategory = .startupKeyMismatch + default: + failureCategory = .startupOtherFailure + } + } else { + failureCategory = .startupOtherFailure + } + VeilPreConsentBlackBox.shared.record(failureCategory, stage: .startupFailure) diagnostics.recordStartupFailure(error) } } @@ -298,6 +317,8 @@ private struct StartupFailureView: View { .foregroundColor(VeilTheme.secondaryText) .textSelection(.enabled) } + VeilPreConsentBlackBoxButton(stage: .startupFailure) + .frame(maxWidth: 520) } .padding(28) .frame(maxWidth: 520) diff --git a/VeilLink/ArcadeRealtime/PlatformerView.swift b/VeilLink/ArcadeRealtime/PlatformerView.swift index dc542624..c7e0080e 100644 --- a/VeilLink/ArcadeRealtime/PlatformerView.swift +++ b/VeilLink/ArcadeRealtime/PlatformerView.swift @@ -8,6 +8,7 @@ import SwiftUI struct VeilPulseRunnerView: View { @StateObject private var session = PlatformerGameSession() @State private var pausedByLifecycle = false + @State private var jumpHeld = false @Environment(\.scenePhase) private var scenePhase var body: some View { @@ -63,12 +64,24 @@ struct VeilPulseRunnerView: View { pausedByLifecycle = false session.resume() } - } else if session.phase == .playing { - pausedByLifecycle = true - session.pause() + } else { + jumpHeld = false + session.releaseJump() + if session.phase == .playing { + pausedByLifecycle = true + session.pause() + } + } + } + .onChange(of: session.phase) { phase in + if phase != .playing { + jumpHeld = false + session.releaseJump() } } .onDisappear { + jumpHeld = false + session.releaseJump() if session.phase == .playing { pausedByLifecycle = true } session.pause() } @@ -163,8 +176,15 @@ struct VeilPulseRunnerView: View { .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 0) - .onChanged { _ in session.jump() } - .onEnded { _ in session.releaseJump() } + .onChanged { _ in + guard !jumpHeld else { return } + jumpHeld = true + session.jump() + } + .onEnded { _ in + jumpHeld = false + session.releaseJump() + } ) .accessibilityAddTraits(.isButton) .accessibilityLabel("跳跃") diff --git a/VeilLink/Core/LatticeClashGame.swift b/VeilLink/Core/LatticeClashGame.swift new file mode 100644 index 00000000..fb4891bd --- /dev/null +++ b/VeilLink/Core/LatticeClashGame.swift @@ -0,0 +1,326 @@ +import Foundation + +enum LatticeClashPlayer: UInt8, CaseIterable, Equatable, Sendable { + case host = 1 + case guest = 2 + + var opponent: LatticeClashPlayer { + self == .host ? .guest : .host + } +} + +enum LatticeClashCell: UInt8, Equatable, Sendable { + case empty = 0 + case host = 1 + case guest = 2 + + init(owner: LatticeClashPlayer) { + self = owner == .host ? .host : .guest + } + + var owner: LatticeClashPlayer? { + switch self { + case .empty: return nil + case .host: return .host + case .guest: return .guest + } + } +} + +struct LatticeClashMove: Equatable, Sendable { + let index: Int +} + +struct LatticeClashTurnResult: Equatable, Sendable { + let actor: LatticeClashPlayer + let move: LatticeClashMove + let captured: [Int] + let hostCount: Int + let guestCount: Int + let retainedTurn: Bool +} + +struct LatticeClashState: Equatable, Sendable { + static let size = 7 + static let cellCount = size * size + static let majorityCount = 25 + static let maxTurns = cellCount - 4 + + private(set) var board: [LatticeClashCell] + private(set) var currentPlayer: LatticeClashPlayer + private(set) var turn: Int + private(set) var winner: LatticeClashPlayer? + private(set) var isDraw: Bool + private(set) var lastMove: Int? + private(set) var lastTurn: LatticeClashTurnResult? + + init() { + var board = Array(repeating: LatticeClashCell.empty, count: Self.cellCount) + board[Self.index(row: 2, column: 0)] = .host + board[Self.index(row: 4, column: 0)] = .host + board[Self.index(row: 2, column: 6)] = .guest + board[Self.index(row: 4, column: 6)] = .guest + self.init(board: board, currentPlayer: .host) + } + + init( + board: [LatticeClashCell], + currentPlayer: LatticeClashPlayer, + turn: Int = 0, + winner: LatticeClashPlayer? = nil, + isDraw: Bool = false, + lastMove: Int? = nil, + lastTurn: LatticeClashTurnResult? = nil + ) { + precondition(board.count == Self.cellCount, "Lattice Clash requires a 7x7 board") + self.board = board + self.currentPlayer = currentPlayer + self.turn = max(0, turn) + self.winner = winner + self.isDraw = isDraw + self.lastMove = lastMove + self.lastTurn = lastTurn + } + + var isFinished: Bool { + winner != nil || isDraw + } + + func cell(at index: Int) -> LatticeClashCell { + guard (0.. Int { + board.filter { $0.owner == player }.count + } + + func legalMoves(for player: LatticeClashPlayer) -> [LatticeClashMove] { + guard !isFinished else { return [] } + return rawLegalMoves(for: player) + } + + func isLegal(move: LatticeClashMove, actor: LatticeClashPlayer) -> Bool { + guard !isFinished, + actor == currentPlayer, + (0.. Bool { + guard isLegal(move: move, actor: actor) else { return false } + + board[move.index] = LatticeClashCell(owner: actor) + let captured = resolveCaptures(for: actor) + turn += 1 + lastMove = move.index + + if count(for: actor) >= Self.majorityCount { + winner = actor + } else if turn >= Self.maxTurns || !board.contains(.empty) { + finishByScore() + } else { + let opponent = actor.opponent + let opponentMoves = rawLegalMoves(for: opponent) + if !opponentMoves.isEmpty { + currentPlayer = opponent + } else if !rawLegalMoves(for: actor).isEmpty { + currentPlayer = actor + } else { + finishByScore() + } + } + + lastTurn = LatticeClashTurnResult( + actor: actor, + move: move, + captured: captured, + hostCount: count(for: .host), + guestCount: count(for: .guest), + retainedTurn: !isFinished && currentPlayer == actor + ) + return true + } + + private func rawLegalMoves(for player: LatticeClashPlayer) -> [LatticeClashMove] { + guard count(for: player) > 0 else { return [] } + var moves: [LatticeClashMove] = [] + moves.reserveCapacity(Self.cellCount) + for index in 0.. [Int] { + var captured: [Int] = [] + let enemy = actor.opponent + + while true { + var wave: [Int] = [] + for index in 0..= 3 { + wave.append(index) + } + } + + guard !wave.isEmpty else { break } + for index in wave { + board[index] = LatticeClashCell(owner: actor) + } + captured.append(contentsOf: wave) + } + + return captured + } + + private mutating func finishByScore() { + let host = count(for: .host) + let guest = count(for: .guest) + if host == guest { + winner = nil + isDraw = true + } else { + winner = host > guest ? .host : .guest + isDraw = false + } + } + + static func index(row: Int, column: Int) -> Int { + row * size + column + } + + static func rowColumn(for index: Int) -> (row: Int, column: Int) { + (index / size, index % size) + } + + static func neighbors(of index: Int) -> [Int] { + guard (0.. LatticeClashMove? { + guard !state.isFinished, state.currentPlayer == actor else { return nil } + let legal = state.legalMoves(for: actor) + guard !legal.isEmpty else { return nil } + + var bestMove = legal[0] + var bestScore = Int.min + + for move in legal { + var probe = state + guard probe.apply(move: move, actor: actor) else { continue } + let score = search(state: probe, perspective: actor, depth: 1) + if score > bestScore || (score == bestScore && move.index < bestMove.index) { + bestScore = score + bestMove = move + } + } + return bestMove + } + + private static func search( + state: LatticeClashState, + perspective: LatticeClashPlayer, + depth: Int + ) -> Int { + if depth == 0 || state.isFinished { + return evaluate(state: state, perspective: perspective) + } + + let actor = state.currentPlayer + let moves = state.legalMoves(for: actor) + guard !moves.isEmpty else { + return evaluate(state: state, perspective: perspective) + } + + if actor == perspective { + var best = Int.min + for move in moves { + var probe = state + guard probe.apply(move: move, actor: actor) else { continue } + best = max(best, search(state: probe, perspective: perspective, depth: depth - 1)) + } + return best + } else { + var best = Int.max + for move in moves { + var probe = state + guard probe.apply(move: move, actor: actor) else { continue } + best = min(best, search(state: probe, perspective: perspective, depth: depth - 1)) + } + return best + } + } + + private static func evaluate( + state: LatticeClashState, + perspective: LatticeClashPlayer + ) -> Int { + if state.winner == perspective { + return 1_000_000 - state.turn + } + if state.winner == perspective.opponent { + return -1_000_000 + state.turn + } + if state.isDraw { + return 0 + } + + let ownCount = state.count(for: perspective) + let enemyCount = state.count(for: perspective.opponent) + let ownMobility = state.legalMoves(for: perspective).count + let enemyMobility = state.legalMoves(for: perspective.opponent).count + let center = centrality(in: state, player: perspective) + - centrality(in: state, player: perspective.opponent) + + return (ownCount - enemyCount) * 100 + + (ownMobility - enemyMobility) * 6 + + center * 2 + } + + private static func centrality( + in state: LatticeClashState, + player: LatticeClashPlayer + ) -> Int { + let midpoint = LatticeClashState.size / 2 + var score = 0 + + for index in 0..= 2 && (0..<32).contains($0[0]) && (0..<32).contains($0[1]) } } + var points: [[Double]] { Array((raw["points"] as? [[Double]] ?? []).prefix(128)).filter { $0.count >= 2 && $0[0].isFinite && $0[1].isFinite && abs($0[0]) <= 1e5 && abs($0[1]) <= 1e5 } } + var grid: [[Int]] { Array((raw["grid"] as? [[Int]] ?? []).prefix(32)).map { Array($0.prefix(32)) } } + var strings: [String] { Array((raw["strings"] as? [String] ?? []).prefix(256)).map { String($0.prefix(512)) } } + var limit: Int { max(1,min(512,(raw["limit"] as? Int) ?? 10)) } +} + +enum VeilAetherMath { + static func fmt(_ x: Double) -> String { x.isFinite ? String(format: "%.4f", x) : "n/a" } + static func mean(_ a: [Double]) -> Double { a.isEmpty ? 0 : a.reduce(0,+)/Double(a.count) } + static func variance(_ a: [Double]) -> Double { let m=mean(a); return a.isEmpty ? 0 : a.map { ($0-m)*($0-m) }.reduce(0,+)/Double(a.count) } + static func median(_ a: [Double]) -> Double { + let s=a.sorted(); guard !s.isEmpty else { return 0 }; let mid=s.count/2 + return s.count%2==0 ? (s[mid-1]+s[mid])/2 : s[mid] + } + static func quantile(_ a: [Double], _ q: Double) -> Double { + let s=a.sorted(); guard !s.isEmpty else { return 0 } + return s[max(0,min(s.count-1,Int(ceil(Double(s.count)*max(0,min(1,q))))-1))] + } + static func entropy(_ a: [Int]) -> Double { + guard !a.isEmpty else { return 0 } + let f=Dictionary(a.map { ($0,1) },uniquingKeysWith:+) + return f.values.reduce(0) { r,c in let p=Double(c)/Double(a.count); return r - p*log2(p) } + } + static func checksum(_ a: [UInt8]) -> UInt32 { + var crc:UInt32=0xFFFFFFFF + for byte in a { crc ^= UInt32(byte); for _ in 0..<8 { let b=crc & 1; crc=(crc >> 1) ^ (b == 1 ? 0xEDB88320:0) } } + return ~crc + } + static func uniquePairs(_ a: [String]) -> Int { a.count - Set(a).count } + static func matrix(_ n: Int, edges: [[Int]], undirected: Bool = true) -> [[Bool]] { + var g=Array(repeating:Array(repeating:false,count:n),count:n) + for edge in edges where edge[0]Int { + let n=g.count; var visited=Set(); var count=0 + for i in 0..Int { + let a=Array(aa.prefix(256)),b=Array(bb.prefix(256));var last=Array(0...b.count) + for (i,c) in a.enumerated(){ var now=[i+1]+Array(repeating:0,count:b.count) + for (j,d) in b.enumerated(){ now[j+1]=min(now[j]+1,last[j+1]+1,last[j]+(c==d ? 0:1)) }; last=now + } + return last[b.count] + } + static func jaccard(_ a:Set,_ b:Set)->Double { + let union=a.union(b); return union.isEmpty ? 1 : Double(a.intersection(b).count)/Double(union.count) + } + static func dist(_ a:[Double],_ b:[Double])->Double {hypot(a[0]-b[0],a[1]-b[1])} + static func polygonArea(_ p:[[Double]])->Double { + guard p.count >= 3 else { return 0 }; var s=0.0 + for i in 0..Int {max(0,min(1000000,x))} + static func numeric(_ x:Double)->String {fmt(x)} +} + +enum VeilAetherEngine { + static let domains: [(String, [String])] = [ + ("Mesh 拓扑研究", ["节点度分布","连通分量剖析","最短跳数路径","环路风险扫描","有向拓扑排序","节点失效连通率","双路无交叉容量","PageRank 中继评分","邻接闭三角率","核心层级剥离"]), + ("无线信号科学", ["采样均值曲线","鲁棒中位估计","截尾均值降噪","信号分位数","采样方差","MAD 离群探测","EWMA 平滑追踪","CUSUM 突变侦测","局部峰值提取","信号信息熵"]), + ("多设备协作", ["逻辑时间排序","Lamport 单调审计","向量时钟偏序","消息序号空洞","多数派门限","副本交集相似度","事件去重账簿","因果依赖缺失","依赖图关键路径","加权公平分配"]), + ("密码学外围与隐私", ["文本香农熵","数据脱敏项计数","邮箱安全掩码","别名冲突扫描","分组时间模糊化","K 匿名组审计","CRC32 完整性校验","Nonce 重复风险","时间戳有效窗口","元数据预算审查"]), + ("音画创作实验", ["调色板亮度","WCAG 对比评级","互补色生成","颜色调色阶","颜色相对亮度","镜头帧时长","BPM 拍点网格","动画缓动曲线","二次贝塞尔采样","音量包络压缩"]), + ("程序化游戏生成", ["地牢路径广搜","格点 A* 规划","生命游戏演化","种子地形生成","细胞区域着色","遮挡视线计算","图块区域泛洪","事件回放验序","关卡难度估计","精灵 LOD 分级"]), + ("离线知识图谱", ["词汇统计","独特词表提炼","文本 Jaccard","编辑距离相似","最长公共子序列","高频双字模型","离线倒排索引","关键词密度","句段分割","重复语句去重"]), + ("几何与场景空间", ["多边形面积","点在多边形内","二维凸包生成","矩形碰撞检测","最近空间点","线段交点","折线 RDP 简化","点云旋转变换","曲线位置插值","网格占据率"]), + ("系统可靠性试验", ["消息 P95 时延","RTT 指数平滑","退避预算模拟","重试次数预算","队列燃尽预估","平均修复时间","剩余存储估计","热阈值负载建议","电量保留预算","综合风险评分"]), + ("触控与无障碍", ["最小可触达区域","多层颜色对比","焦点图环路","文本方向异常","布局溢出预警","动效总时长门禁","手势冲突概率","触控覆盖总量","告警文字易读性","触觉节奏一致性"]) + ] + static let catalog: [VeilAetherFeature] = domains.enumerated().flatMap { group, pair in + pair.1.enumerated().map { index,title in + VeilAetherFeature(number:201+group*10+index,title:title,domain:pair.0, + purpose:"独立离线运算模块;仅分析用户主动提供的去标识化样本") + } + } + static let exampleJSON = """ + {"numbers":[1,2,3,5,8,13,21,3,2,1],"text":"VeilLink offline local science offline", "strings":["alpha","beta","alpha","gamma"],"edges":[[0,1],[1,2],[2,3],[1,3],[3,4]],"points":[[0,0],[4,0],[4,3],[0,3]],"grid":[[0,0,1,0],[0,0,0,0],[1,0,0,0],[0,0,0,0]],"flags":[true,false,true,true],"limit":5} + """ + static func run(_ id:Int,json:String) throws -> VeilAetherReport { + guard let feature=catalog.first(where:{$0.number==id}) else {throw VeilAetherError.invalidFeature} + let input=try VeilAetherInput(json) + let index=id-201 + let answer:String + switch index/10 { + case 0: answer=VeilAetherGraph.run(index%10,input) + case 1: answer=VeilAetherSignal.run(index%10,input) + case 2: answer=VeilAetherSync.run(index%10,input) + case 3: answer=VeilAetherPrivacy.run(index%10,input) + case 4: answer=VeilAetherMedia.run(index%10,input) + case 5: answer=VeilAetherGames.run(index%10,input) + case 6: answer=VeilAetherKnowledge.run(index%10,input) + case 7: answer=VeilAetherSpatial.run(index%10,input) + case 8: answer=VeilAetherReliability.run(index%10,input) + case 9: answer=VeilAetherAccessibility.run(index%10,input) + default: throw VeilAetherError.invalidFeature + } + return VeilAetherReport(featureID:id,title:feature.title,output:answer, + note:"人工输入样本|本地建议性计算|非真实设备检测结果") + } +} diff --git a/VeilLink/Core/VeilAetherLabFamiliesA.swift b/VeilLink/Core/VeilAetherLabFamiliesA.swift new file mode 100644 index 00000000..a9b9abcb --- /dev/null +++ b/VeilLink/Core/VeilAetherLabFamiliesA.swift @@ -0,0 +1,224 @@ +import Foundation + +private typealias AM = VeilAetherMath + +enum VeilAetherGraph { + static func run(_ k:Int,_ i:VeilAetherInput)->String { + let g=AM.matrix(16,edges:i.edges);let n=g.count + switch k { + case 0: + return "各节点度数: " + (0..();var found=false + func dfs(_ u:Int,_ parent:Int){visited.insert(u);for v in 0..0 && parent[v]<0 {parent[v]=u;q.append(v)} + } + guard parent[4]>=0 else{break} + var v=4 + while v != 0 {let u=parent[v];capacity[u][v]-=1;capacity[v][u]+=1;v=u} + flow+=1 + } + return "0→4 无公共边冗余路径: \(flow) 条" + case 7: + let d=AM.matrix(16,edges:i.edges,undirected:false) + var rank=Array(repeating:1.0/Double(n),count:n) + for _ in 0..<20 { + var next=Array(repeating:0.15/Double(n),count:n) + for u in 0..1 {wedges+=neighbors.count*(neighbors.count-1)/2 + for a in 0..16{break}} + return "\(threshold)-core 节点数: \(active.count) (轮次\(depth))" + } + } +} + +enum VeilAetherSignal { + static func run(_ k:Int,_ i:VeilAetherInput)->String { + let a=i.nums;let m=AM.mean(a) + switch k { + case 0: return "均值 \(AM.fmt(m))" + case 1: return "中位数 \(AM.fmt(AM.median(a)))" + case 2: + let v=a.sorted();let d=v.count/10;let trimmed=Array(v.dropFirst(d).dropLast(d)) + return "10% 截尾均值 \(AM.fmt(AM.mean(trimmed)))" + case 3: return "P90 \(AM.fmt(AM.quantile(a,0.90)))" + case 4: return "方差 \(AM.fmt(AM.variance(a)))" + case 5: + let med=AM.median(a),mad=AM.median(a.map{abs($0-med)}) + let outliers=a.filter{mad>0 ? abs($0-med)>3*1.4826*mad : abs($0-med)>0} + return "MAD 异常点数 \(outliers.count)" + case 6: + var ema=a.first ?? 0;for x in a.dropFirst(){ema=0.25*x+0.75*ema} + return "EWMA \(AM.fmt(ema))" + case 7: + var pos=0.0,negative=0.0,maxDeviation=0.0 + let sigma=sqrt(AM.variance(a));let slack=max(0.001,sigma/2) + for x in a {pos=max(0,pos+x-m-slack);negative=min(0,negative+x-m+slack);maxDeviation=max(maxDeviation,pos,abs(negative))} + return "CUSUM 偏移幅度 \(AM.fmt(maxDeviation))" + case 8: + let count=a.count<3 ? 0 : (1..<(a.count-1)).filter{a[$0]>a[$0-1] && a[$0]>=a[$0+1]}.count + return "局部峰值 \(count) 个" + default: + let bins=a.map{Int(($0*10).rounded())};return "离散 Shannon 熵 \(AM.fmt(AM.entropy(bins))) bit" + } + } +} + +enum VeilAetherSync { + static func run(_ k:Int,_ i:VeilAetherInput)->String { + let a=i.ints,labels=i.strings,e=i.edges + switch k { + case 0: + let sorted=a.enumerated().sorted{ $0.element==$1.element ? $0.offset<$1.offset : $0.element<$1.element } + return "稳定逻辑顺序: "+sorted.prefix(15).map{String($0.offset)}.joined(separator:",") + case 1: return "Lamport 回退 \(zip(a,a.dropFirst()).filter{$0.1<=$0.0}.count) 次" + case 2: + let first=Array(a.prefix(3)),second=Array(a.dropFirst(3).prefix(3)) + guard first.count==3 && second.count==3 else{return "需至少6个数构成两个向量时钟"} + let f=zip(first,second).allSatisfy{$0<=$1},b=zip(first,second).allSatisfy{$0>=$1} + return f && b ? "同一时钟" : f ? "A 先于 B" : b ? "B 先于 A" : "并发无序" + case 3: + let unique=Array(Set(a)).sorted();let gaps=zip(unique,unique.dropFirst()).reduce(0){$0+max(0,min(1000,$1.1-$1.0-1))} + return "消息序列缺口 \(gaps)" + case 4: + let approved=i.flags.filter{$0}.count;let threshold=i.flags.count/2+1 + return "多数派门限 \(approved)/\(threshold) \(approved>=threshold ? "满足":"不足")" + case 5: + let l=Set(labels.enumerated().compactMap{$0.offset%2==0 ? $0.element:nil}) + let r=Set(labels.enumerated().compactMap{$0.offset%2==1 ? $0.element:nil}) + return "副本交并比 \(AM.fmt(AM.jaccard(l,r)))" + case 6: return "重复操作 ID \(AM.uniquePairs(labels)) 个" + case 7: + let seen=Set(e.map{$0[0]});let unknown=Set(e.map{$0[1]}).subtracting(seen) + return "缺失父事件 \(unknown.count) 个" + case 8: + let n=16,g=AM.matrix(n,edges:e,undirected:false) + var indeg=Array(repeating:0,count:n);for u in 0..String { + let text=i.text, strings=i.strings,a=i.ints + switch k { + case 0: return "文本字节熵 \(AM.fmt(AM.entropy(Array(text.utf8).map(Int.init)))) bit" + case 1: + let patterns=["password","token","apikey","secret","手机号","密钥"] + return "待人工审核的敏感字段标记 \(patterns.filter{text.lowercased().contains($0)}.count)" + case 2: + let parts=text.split(separator:"@",maxSplits:1).map(String.init) + guard parts.count==2,parts[0].count>=2 else{return "非有效邮箱格式"} + return "邮箱掩码: \(parts[0].prefix(1))***@\(parts[1])" + case 3: return "别名重复 \(AM.uniquePairs(strings.map{$0.lowercased()})) 条" + case 4: + let buckets=a.map{Int(floor(Double($0)/300))*300};return "5分钟时间桶: "+buckets.prefix(10).map(String.init).joined(separator:",") + case 5: + let frequency=Dictionary(strings.map{($0,1)},uniquingKeysWith:+) + let bad=frequency.values.filter{$0=now && expiry-now<=3600 ? "窗口内" : "超时或异常")" + default: + let bytes=strings.reduce(0){$0+$1.utf8.count} + return "元数据 \(bytes) B / 4096 B \(bytes<=4096 ? "可审阅":"超出预算")" + } + } +} + +enum VeilAetherMedia { + private static func srgb(_ x:Double)->Double{let x=max(0,min(1,x));return x<=0.04045 ? x/12.92 : pow((x+0.055)/1.055,2.4)} + private static func col(_ a:[Int],_ offset:Int)->[Double]{let p=Array(a.dropFirst(offset).prefix(3));return p.count==3 ? p.map{Double(max(0,min(255,$0)))/255} : [0,0,0]} + private static func luminance(_ rgb:[Double])->Double{0.2126*srgb(rgb[0])+0.7152*srgb(rgb[1])+0.0722*srgb(rgb[2])} + static func run(_ k:Int,_ i:VeilAetherInput)->String{ + let a=i.nums,ints=i.ints,c=col(ints,0),d=col(ints,3),lum=luminance(c) + switch k { + case 0: return "颜色平均明度 \(AM.fmt(AM.mean(c)))" + case 1: + let l2=luminance(d);return "WCAG 对比度 \(AM.fmt((max(lum,l2)+0.05)/(min(lum,l2)+0.05))):1" + case 2: return "互补色 RGB: "+c.map{String(Int((1-$0)*255))}.joined(separator:",") + case 3: + let levels=c.map{Int(($0*5).rounded())};return "五级调色阶: "+levels.map(String.init).joined(separator:",") + case 4: return "相对亮度 \(AM.fmt(lum))" + case 5: + let frames=Double(abs(ints.first ?? 0)),fps=Double(max(1,min(120,abs(ints.dropFirst().first ?? 30)))) + return "镜头时长 \(AM.fmt(frames/fps)) s" + case 6: + let bpm=max(20,min(320,abs(a.first ?? 120)));return "节拍间隔 \(AM.fmt(60/bpm)) s" + case 7: + let t=max(0,min(1,abs(a.first ?? 0).truncatingRemainder(dividingBy:1))) + return "Smoothstep 缓动 \(AM.fmt(t*t*(3-2*t)))" + case 8: + let p=i.points;guard p.count>=3 else{return "需要三个控制点"} + let t=0.5,x=pow(1-t,2)*p[0][0]+2*t*(1-t)*p[1][0]+t*t*p[2][0] + let y=pow(1-t,2)*p[0][1]+2*t*(1-t)*p[1][1]+t*t*p[2][1] + return "Bezier 50%: (\(AM.fmt(x)),\(AM.fmt(y)))" + default: + let peak=a.map{abs($0)}.max() ?? 0;let compressed=peak<=1 ? peak : 1+log2(peak) + return "音量包络压缩峰值 \(AM.fmt(compressed))" + } + } +} diff --git a/VeilLink/Core/VeilAetherLabFamiliesB.swift b/VeilLink/Core/VeilAetherLabFamiliesB.swift new file mode 100644 index 00000000..1fe3af66 --- /dev/null +++ b/VeilLink/Core/VeilAetherLabFamiliesB.swift @@ -0,0 +1,241 @@ +import Foundation + +private typealias BM = VeilAetherMath + +enum VeilAetherGames { + private static func grid(_ i:VeilAetherInput)->[[Int]] { + let raw=i.grid;guard raw.count>=2,raw.count<=32,raw.allSatisfy({$0.count==raw[0].count}),raw[0].count>=2 else { + return [[0,0,1,0],[0,0,0,0],[1,0,0,0],[0,0,0,0]] + };return raw + } + private static func search(_ board:[[Int]], aStar:Bool)->Int { + let h=board.count,w=board[0].count;guard board[0][0]==0,board[h-1][w-1]==0 else{return -1} + var d=Array(repeating:Array(repeating:Int.max,count:w),count:h);d[0][0]=0 + var open=[(0,0)],found=0 + while !open.isEmpty && found<2048 { + found+=1 + if aStar {open.sort{d[$0.0][$0.1]+abs(h-1-$0.0)+abs(w-1-$0.1)=0 && nr=0 && ncString{ + let b=grid(i),h=b.count,w=b[0].count,ints=i.ints + switch k { + case 0: return "地牢 BFS 路径步数 \(search(b,aStar:false))" + case 1: return "A* 格点路径步数 \(search(b,aStar:true))" + case 2: + var alive=0 + for r in 0..=0 && nr=0 && nc>7;s ^= s<<17;values.append(Int(s%10))} + return "种子地形: "+values.map(String.init).joined(separator:",") + case 4: + let palette=Set(b.flatMap{$0});return "地图图块种类 \(palette.count)" + case 5: + var blocked=0;let row=0,col=0 + for index in 1..(),regions=0 + for r in 0..=0 && nr=0 && nc=$0.1}.count + return "回放非单调事件 \(regressions)" + case 8: + let walls=b.flatMap{$0}.filter{$0 != 0}.count + return "关卡阻塞率 \(BM.fmt(100*Double(walls)/Double(h*w)))%" + default: + let density=Double(h*w),cells=min(4096,h*w*max(1,i.limit));return "LOD 采样预算 \(cells) / \(Int(density))" + } + } +} + +enum VeilAetherKnowledge { + static func run(_ k:Int,_ i:VeilAetherInput)->String { + let w=i.words,t=i.text,strings=i.strings + switch k { + case 0: return "词数 \(w.count),字符 \(t.count)" + case 1: return "独立词 \(Set(w).count) 个" + case 2: + let mid=w.count/2;let a=Set(w.prefix(mid)),b=Set(w.dropFirst(mid)) + return "上下文 Jaccard \(BM.fmt(BM.jaccard(a,b)))" + case 3: + let a=strings.first ?? "",b=strings.dropFirst().first ?? "" + return "编辑距离 \(BM.levenshtein(a,b))" + case 4: + let a=Array((strings.first ?? "").prefix(100)),b=Array((strings.dropFirst().first ?? "").prefix(100)) + var dp=Array(repeating:Array(repeating:0,count:b.count+1),count:a.count+1) + if !a.isEmpty && !b.isEmpty{for x in 1...a.count{for y in 1...b.count{dp[x][y]=a[x-1]==b[y-1] ? dp[x-1][y-1]+1 : max(dp[x-1][y],dp[x][y-1])}}} + return "最长公共子序列 \(dp[a.count][b.count])" + case 5: + let letters=Array(t.lowercased());var f=[String:Int]() + if letters.count>=2 {for index in 0..<(letters.count-1){f[String(letters[index...index+1]),default:0]+=1}} + let top=f.sorted{$0.value==$1.value ? $0.key<$1.key : $0.value>$1.value}.first + return "最高频 bigram: \(top?.key ?? "—") (\(top?.value ?? 0))" + case 6: + let dict=Dictionary(grouping:w.enumerated(),by:{$0.element}) + return "倒排索引 \(dict.count) 词项 / \(dict.values.reduce(0){$0+$1.count}) 出现位置" + case 7: + let needle=(strings.first ?? "veil").lowercased();return "关键词密度 \(BM.fmt(w.isEmpty ? 0 : 100*Double(w.filter{$0==needle}.count)/Double(w.count)))%" + case 8: + return "句段 \(t.split(whereSeparator:{"。!?.!?\n".contains($0)}).filter{!$0.trimmingCharacters(in:.whitespaces).isEmpty}.count)" + default: return "去重后语句 \(Set(strings.map{$0.trimmingCharacters(in:.whitespacesAndNewlines)}).count)/\(strings.count)" + } + } +} + +enum VeilAetherSpatial { + static func run(_ k:Int,_ i:VeilAetherInput)->String{ + let p=i.points,n=p.count,values=i.nums + switch k { + case 0: return "面积 \(BM.fmt(BM.polygonArea(p))) 平方单位" + case 1: + guard n>=3 else{return "点数不足"} + let point:[Double]=[values.first ?? 1.0,values.dropFirst().first ?? 1.0];var inside=false,j=n-1 + for k in 0..point[1]) != (yj>point[1]) && point[0] < (p[j][0]-p[k][0])*(point[1]-yi)/(yj-yi)+p[k][0] {inside = !inside} + j=k + } + return "目标点 \(inside ? "在多边形内":"在多边形外")" + case 2: + guard n>1 else{return "凸包节点 \(n)"} + let points=p.sorted{$0[0]==$1[0] ? $0[1]<$1[1] : $0[0]<$1[0]} + func cross(_ a:[Double],_ b:[Double],_ c:[Double])->Double{(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])} + var lower=[[Double]]();for x in points{while lower.count>=2 && cross(lower[lower.count-2],lower[lower.count-1],x)<=0{lower.removeLast()};lower.append(x)} + var upper=[[Double]]();for x in points.reversed(){while upper.count>=2 && cross(upper[upper.count-2],upper[upper.count-1],x)<=0{upper.removeLast()};upper.append(x)} + return "凸包节点 \(lower.count+upper.count-2)" + case 3: + guard n>=2 else{return "需要两组矩形控制点"} + let a=p[0],b=p[1],width=abs(values.first ?? 2),height=abs(values.dropFirst().first ?? 2) + return "矩形碰撞: \(abs(a[0]-b[0])0 else{return "没有坐标"} + let target:[Double]=[values.first ?? 0.0,values.dropFirst().first ?? 0.0] + let hit=p.enumerated().min{BM.dist($0.element,target)=4 else{return "至少四个端点"} + func cross(_ a:[Double],_ b:[Double],_ c:[Double])->Double{(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])} + let a=cross(p[0],p[1],p[2]),b=cross(p[0],p[1],p[3]),c=cross(p[2],p[3],p[0]),d=cross(p[2],p[3],p[1]) + return "两线段严格交叉: \(a*b<0 && c*d<0 ? "是":"否(含边界未判定)")" + case 6: + guard n>=2 else{return "简化后点数 \(n)"} + let tolerance=max(0.001,min(1e3,abs(values.first ?? 0.5))) + func simplify(_ nodes:[[Double]])->[[Double]]{ + if nodes.count<3{return nodes};let a=nodes[0],b=nodes[nodes.count-1],ab=BM.dist(a,b) + var maxDistance = -1.0,index=0 + for j in 1..<(nodes.count-1){let q=nodes[j],distance=ab<1e-12 ? BM.dist(a,q) : abs((b[1]-a[1])*q[0]-(b[0]-a[0])*q[1]+b[0]*a[1]-b[1]*a[0])/ab + if distance>maxDistance{maxDistance=distance;index=j} + } + if maxDistance<=tolerance{return [a,b]} + let left=simplify(Array(nodes[0...index]));let right=simplify(Array(nodes[index...]));return Array(left.dropLast())+right + } + return "RDP 简化 \(n) → \(simplify(p).count) 点" + case 7: + guard let point=p.first else{return "坐标缺失"};let angle=(values.first ?? 45)*Double.pi/180 + return "旋转后的首点 \(BM.fmt(point[0]*cos(angle)-point[1]*sin(angle))),\(BM.fmt(point[0]*sin(angle)+point[1]*cos(angle)))" + case 8: + guard n>=2 else{return "至少两个控制点"};let t=0.35 + return "线性插值 \(BM.fmt(p[0][0]*(1-t)+p[1][0]*t)),\(BM.fmt(p[0][1]*(1-t)+p[1][1]*t))" + default: + let cells=i.grid.flatMap{$0};return "网格占据率 \(BM.fmt(cells.isEmpty ? 0 : 100*Double(cells.filter{$0 != 0}.count)/Double(cells.count)))%" + } + } +} + +enum VeilAetherReliability { + static func run(_ k:Int,_ i:VeilAetherInput)->String { + let a=i.nums + switch k { + case 0: return "消息 P95 \(BM.fmt(BM.quantile(a,0.95))) ms" + case 1: + var smooth=a.first ?? 0;for x in a.dropFirst(){smooth=0.2*x+0.8*smooth} + return "平滑 RTT \(BM.fmt(smooth)) ms" + case 2: + let retries=max(0,min(8,abs(i.ints.first ?? 3))) + let delays=(0..0};return "平均修复时间 \(BM.fmt(BM.mean(incidents))) 分钟" + case 6: + let free=max(0,a.first ?? 0),daily=max(0,a.dropFirst().first ?? 0) + return "存储剩余 \(daily>0 ? BM.fmt(free/daily) : "未知") 天(不自动清理)" + case 7: + let thermal=max(0,min(3,i.ints.first ?? 0));return "热等级 \(thermal),建议特效预算 \([100,60,25,0][thermal])%" + case 8: + let charge=max(0,min(100,Int(a.first ?? 100)));return "电量 \(charge)% / 非关键特效 \(charge<15 ? 20:charge<30 ? 55:100)%" + default: + let danger=min(100,max(0,Int(BM.mean(a.map{abs($0)}))));return "综合样本风险 \(danger)/100(非安全认证)" + } + } +} + +enum VeilAetherAccessibility { + static func run(_ k:Int,_ i:VeilAetherInput)->String{ + let a=i.nums,strings=i.strings,ints=i.ints + switch k { + case 0: + let widths=a.enumerated().compactMap{$0.offset%2==0 ? $0.element:nil};let heights=a.enumerated().compactMap{$0.offset%2==1 ? $0.element:nil} + let n=min(widths.count,heights.count),small=(0..Double{let v=c.map{x in x<=0.04045 ? x/12.92 : pow((x+0.055)/1.055,2.4)};return v[0]*0.2126+v[1]*0.7152+v[2]*0.0722} + let x=lum(Array(values[0..<3])),y=lum(Array(values[3..<6])) + return "图层文字对比度 \(BM.fmt((max(x,y)+0.05)/(min(x,y)+0.05)))" + case 2: + let graph=BM.matrix(16,edges:i.edges,undirected:false);var state=Array(repeating:0,count:16),cycle=false + func visit(_ u:Int){state[u]=1;for v in 0..<16 where graph[u][v]{if state[v]==1{cycle=true}else if state[v]==0{visit(v)}};state[u]=2} + for u in 0..<16 where state[u]==0{visit(u)} + return "焦点序列环路 \(cycle ? "存在":"未发现")" + case 3: + let bidi=i.text.unicodeScalars.filter{(0x202A...0x202E).contains($0.value)||(0x2066...0x2069).contains($0.value)}.count + return "双向控制字符 \(bidi) 个(需人工检查)" + case 4: + let maxLen=strings.map{$0.count}.max() ?? 0;return "最大文案 \(maxLen) 字符,\(maxLen>45 ? "建议检查小屏换行":"低风险")" + case 5: + let total=a.map{max(0,$0)}.reduce(0,+);return "动效时间 \(BM.fmt(total)) ms,\(total>2500 ? "建议缩减":"可接受")" + case 6: + let duplicate=i.strings.count-Set(i.strings).count + return "共享手势标签重复 \(duplicate) 项(不代表真实冲突)" + case 7: + let count=i.flags.filter{$0}.count;return "叠加层拦截状态 \(count)/\(i.flags.count) 层" + case 8: + let tooLong=strings.filter{$0.count>100}.count + return "冗长错误提示 \(tooLong) 条" + default: + let times=zip(a,a.dropFirst()).map{abs($0.0-$0.1)} + return "触觉间隔方差 \(BM.fmt(BM.variance(times)))" + } + } +} diff --git a/VeilLink/Core/VeilAppleMaterialEngine.swift b/VeilLink/Core/VeilAppleMaterialEngine.swift index dbbcc7e8..4e008426 100644 --- a/VeilLink/Core/VeilAppleMaterialEngine.swift +++ b/VeilLink/Core/VeilAppleMaterialEngine.swift @@ -54,6 +54,7 @@ struct VeilPlatformGlassSurfaceModifier: ViewModifier { .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(VeilTheme.hairline, lineWidth: 1) + .allowsHitTesting(false) ) } else { content @@ -66,6 +67,7 @@ struct VeilPlatformGlassSurfaceModifier: ViewModifier { .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(appearance.isDarkAppearance ? Color.white.opacity(0.10) : Color.white.opacity(0.72), lineWidth: 0.8) + .allowsHitTesting(false) ) .shadow(color: Color.black.opacity(appearance.isDarkAppearance ? 0.24 : 0.10), radius: 14, x: 0, y: 8) } diff --git a/VeilLink/Core/VeilEmotionGardenEngine.swift b/VeilLink/Core/VeilEmotionGardenEngine.swift new file mode 100644 index 00000000..8441f7cb --- /dev/null +++ b/VeilLink/Core/VeilEmotionGardenEngine.swift @@ -0,0 +1,299 @@ +import Foundation + +enum VeilEmotionTone: String, CaseIterable, Identifiable, Sendable { + case calm + case low + case tense + case tired + case lonely + case joyful + case proud + case uncertain + case focused + case playful + + var id: String { rawValue } + + var title: String { + switch self { + case .calm: return "平静" + case .low: return "低落" + case .tense: return "紧绷" + case .tired: return "疲惫" + case .lonely: return "孤单" + case .joyful: return "愉快" + case .proud: return "有成就感" + case .uncertain: return "不确定" + case .focused: return "想专注" + case .playful: return "想玩一会" + } + } +} + +enum VeilEmotionRitualKind: String, CaseIterable, Identifiable, Sendable { + case notice + case breath + case name + case kindness + case anchor + case gratitude + case smallWin + case connection + case reset + case play + + var id: String { rawValue } + + var title: String { + switch self { + case .notice: return "一分钟觉察" + case .breath: return "呼吸节拍" + case .name: return "给感受命名" + case .kindness: return "给自己一句话" + case .anchor: return "感官锚点" + case .gratitude: return "微小感谢" + case .smallWin: return "收集小胜利" + case .connection: return "轻连接" + case .reset: return "温和重启" + case .play: return "一点玩心" + } + } +} + +enum VeilEmotionContext: String, CaseIterable, Identifiable, Sendable { + case morning + case midday + case night + case alone + case commute + case workBreak + case afterCompletion + case waiting + case rainyDay + case specialDay + + var id: String { rawValue } + + var title: String { + switch self { + case .morning: return "晨间" + case .midday: return "午间" + case .night: return "夜间" + case .alone: return "独处" + case .commute: return "通勤" + case .workBreak: return "工作间隙" + case .afterCompletion: return "完成之后" + case .waiting: return "等待中" + case .rainyDay: return "雨天" + case .specialDay: return "特别日子" + } + } +} + +struct VeilEmotionRitual: Identifiable, Equatable, Sendable { + let id: String + let tone: VeilEmotionTone + let kind: VeilEmotionRitualKind + let context: VeilEmotionContext + let title: String + let prompt: String + let steps: [String] + let completionNote: String + let seconds: Int + let symbol: String +} + +enum VeilEmotionGardenEngine { + private struct RouteKey: Hashable, Sendable { + let tone: VeilEmotionTone + let context: VeilEmotionContext + } + + private struct SearchEntry: Sendable { + let ritual: VeilEmotionRitual + let normalizedText: String + } + + /// 1,000 executable micro-rituals: 10 tones × 10 safe ritual forms × 10 contexts. + /// They are local prompts, not diagnosis, therapy, or crisis care. + static let rituals: [VeilEmotionRitual] = { + var result: [VeilEmotionRitual] = [] + result.reserveCapacity(1_000) + for (toneIndex, tone) in VeilEmotionTone.allCases.enumerated() { + for (kindIndex, kind) in VeilEmotionRitualKind.allCases.enumerated() { + for (contextIndex, context) in VeilEmotionContext.allCases.enumerated() { + let number = toneIndex * 100 + kindIndex * 10 + contextIndex + 1 + result.append(VeilEmotionRitual( + id: String(format: "EV-%04d", number), + tone: tone, + kind: kind, + context: context, + title: "\(tone.title) · \(context.title) · \(kind.title)", + prompt: prompt(tone: tone, kind: kind, context: context), + steps: steps(tone: tone, kind: kind, context: context), + completionNote: "你完成了“\(tone.title) · \(context.title) · \(kind.title)”。不必立刻变好,愿意留出这段时间本身就算数。", + seconds: duration(kind, context: context), + symbol: symbol(kind) + )) + } + } + } + return result + }() + + private static let toneIndex = Dictionary(grouping: rituals, by: \.tone) + private static let routeIndex = Dictionary(grouping: rituals) { + RouteKey(tone: $0.tone, context: $0.context) + } + private static let idIndex = Dictionary(uniqueKeysWithValues: rituals.map { ($0.id, $0) }) + private static let searchIndex: [SearchEntry] = rituals.map { ritual in + let text = ([ritual.id, ritual.title, ritual.prompt, ritual.completionNote] + ritual.steps) + .joined(separator: " ") + return SearchEntry(ritual: ritual, normalizedText: normalized(text)) + } + + static func rituals(for tone: VeilEmotionTone) -> [VeilEmotionRitual] { + toneIndex[tone] ?? [] + } + + static func rituals(for tone: VeilEmotionTone, context: VeilEmotionContext) -> [VeilEmotionRitual] { + routeIndex[RouteKey(tone: tone, context: context)] ?? [] + } + + static func ritual(id: String) -> VeilEmotionRitual? { + idIndex[id.uppercased()] + } + + static func search(_ query: String, limit: Int = 50) -> [VeilEmotionRitual] { + let needle = normalized(query.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !needle.isEmpty, limit > 0 else { return [] } + return searchIndex.lazy + .filter { $0.normalizedText.contains(needle) } + .prefix(min(limit, 100)) + .map(\.ritual) + } + + static func dailyPath( + for tone: VeilEmotionTone, + context: VeilEmotionContext, + date: Date, + calendar: Calendar = .current + ) -> [VeilEmotionRitual] { + let source = rituals(for: tone, context: context) + guard !source.isEmpty else { return [] } + let day = calendar.ordinality(of: .day, in: .year, for: date) ?? 1 + let seed = tone.rawValue + context.rawValue + let start = (day + seed.unicodeScalars.reduce(0) { $0 + Int($1.value) }) % source.count + return (0.. String { + switch tone { + case .calm: return "把这份平静留给接下来的十分钟。" + case .low: return "低落不需要立刻被解决;先给自己一点空间。" + case .tense: return "先放慢一点点,就已经是在照顾自己。" + case .tired: return "疲惫不是失败,它在提醒你需要补给。" + case .lonely: return "想被理解是很自然的需要;可以从一条轻消息开始。" + case .joyful: return "值得把这份愉快认真记住。" + case .proud: return "你完成的部分,确实是你做成的。" + case .uncertain: return "不知道下一步也没关系,只选一个最小动作就好。" + case .focused: return "把注意力交给眼前这一小段,而不是全部。" + case .playful: return "一点无目的的玩心,也可以是能量。" + } + } + + private static func duration(_ kind: VeilEmotionRitualKind, context: VeilEmotionContext) -> Int { + let base: Int + switch kind { + case .breath: base = 60 + case .notice, .name, .anchor: base = 45 + case .kindness, .gratitude, .smallWin, .connection, .reset, .play: base = 90 + } + return context == .commute || context == .waiting ? max(30, base - 15) : base + } + + private static func symbol(_ kind: VeilEmotionRitualKind) -> String { + switch kind { + case .notice: return "eye.fill" + case .breath: return "wind" + case .name: return "text.bubble.fill" + case .kindness: return "heart.fill" + case .anchor: return "hand.raised.fill" + case .gratitude: return "sparkles" + case .smallWin: return "checkmark.seal.fill" + case .connection: return "person.2.fill" + case .reset: return "arrow.clockwise" + case .play: return "face.smiling.fill" + } + } + + private static func prompt(tone: VeilEmotionTone, kind: VeilEmotionRitualKind, context: VeilEmotionContext) -> String { + let subjects: [VeilEmotionTone: String] = [ + .calm: "此刻的平静", .low: "这份低落", .tense: "这股紧绷", .tired: "这份疲惫", .lonely: "这份孤单", + .joyful: "这份愉快", .proud: "这份成就感", .uncertain: "这份不确定", .focused: "这份专注", .playful: "这点玩心" + ] + let subject = subjects[tone] ?? "此刻的感受" + let opening: String + switch context { + case .morning: opening = "在今天刚开始的时候," + case .midday: opening = "在一天走到中段的时候," + case .night: opening = "在准备结束今天之前," + case .alone: opening = "在只属于你的这段时间里," + case .commute: opening = "在安全、不影响行程的前提下," + case .workBreak: opening = "把工作暂放片刻," + case .afterCompletion: opening = "完成一件事之后," + case .waiting: opening = "利用眼前这小段等待," + case .rainyDay: opening = "让雨声成为背景," + case .specialDay: opening = "给这个特别的日子留一点位置," + } + switch kind { + case .notice: return "\(opening)不评价地观察 \(subject):身体、呼吸和念头各是什么样?" + case .breath: return "\(opening)带着 \(subject)吸气 3 拍、停留 1 拍、呼气 4 拍;按舒服的速度做 6 轮。" + case .name: return "\(opening)用三个词给 \(subject)命名;不需要解释,也不需要修正。" + case .kindness: return "\(opening)想想朋友若正经历 \(subject),你会说哪句温和的话;也把它留给自己。" + case .anchor: return "\(opening)带着 \(subject)找出能看见的 3 个细节、能触到的 2 个触感、能听到的 1 个声音。" + case .gratitude: return "\(opening)写下一个不必宏大、却让 \(subject)有了细微变化的瞬间。" + case .smallWin: return "\(opening)记录一个在 \(subject)中仍然完成的小动作;再小也算。" + case .connection: return "\(opening)想一个能接住 \(subject)的人或地方;合适时发一条无需回复的轻消息。" + case .reset: return "\(opening)为带着 \(subject)的下一步选一个两分钟动作:喝水、洗脸、站起来或整理一角。" + case .play: return "\(opening)允许 \(subject)和一点玩心同时存在:涂鸦、随手拍、哼一句旋律或看一眼天空。" + } + } + + private static func steps( + tone: VeilEmotionTone, + kind: VeilEmotionRitualKind, + context: VeilEmotionContext + ) -> [String] { + let marker = "\(tone.title) · \(context.title)" + switch kind { + case .notice: + return ["[\(marker)] 放松下颌和肩膀。", "观察一个身体信号,不评价它。", "留意它在三次呼吸后有没有变化。"] + case .breath: + return ["[\(marker)] 找到不会影响安全的位置。", "跟随 3-1-4 节拍呼吸。", "最后一轮恢复自然呼吸并停留片刻。"] + case .name: + return ["[\(marker)] 先写下最先出现的感受词。", "再补两个更具体的词。", "选择其中最贴近现在的一个。"] + case .kindness: + return ["[\(marker)] 想象一位正在经历同样事情的朋友。", "写下一句不会催促对方的话。", "把称呼换成自己,再读一遍。"] + case .anchor: + return ["[\(marker)] 找出三个视觉细节。", "确认两个真实触感。", "听见一个近处或远处的声音。"] + case .gratitude: + return ["[\(marker)] 找一个今天真实发生的小片刻。", "写清它具体好在哪里。", "为它留下一句短短的感谢。"] + case .smallWin: + return ["[\(marker)] 选一个已经完成的小动作。", "说出它需要过哪一道阻力。", "用一句事实记录它,不贬低它。"] + case .connection: + return ["[\(marker)] 想到一位让你较安心的人。", "写一条不要求立即回复的消息。", "确认合适后再由你决定是否发送。"] + case .reset: + return ["[\(marker)] 选一个两分钟内能结束的动作。", "只完成这一件,不追加任务。", "完成后重新决定下一步。"] + case .play: + return ["[\(marker)] 任选涂鸦、哼唱、观察或随手拍。", "允许结果没有用途。", "在计时结束时留下一个你喜欢的细节。"] + } + } + + private static func normalized(_ text: String) -> String { + text.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "zh_Hans_CN") + ) + } +} diff --git a/VeilLink/Core/VeilFeatureRegistry.swift b/VeilLink/Core/VeilFeatureRegistry.swift new file mode 100644 index 00000000..87c410ca --- /dev/null +++ b/VeilLink/Core/VeilFeatureRegistry.swift @@ -0,0 +1,269 @@ +import Foundation + +enum VeilFeatureDomain: String, CaseIterable, Identifiable, Codable, Sendable { + case communication + case productivity + case diagnostics + case entertainment + case emotion + case games + case utilities + case experimental + + var id: String { rawValue } + + var title: String { + switch self { + case .communication: return "通信" + case .productivity: return "效率" + case .diagnostics: return "诊断" + case .entertainment: return "娱乐" + case .emotion: return "陪伴" + case .games: return "游戏" + case .utilities: return "工具" + case .experimental: return "实验室" + } + } +} + +enum VeilFeatureResourceClass: Int, Codable, Comparable, Sendable { + case featherweight = 0 + case standard = 1 + case renderHeavy = 2 + + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } +} + +struct VeilFeatureGate: OptionSet, Hashable, Codable, Sendable { + let rawValue: UInt16 + + static let p0TouchVerified = Self(rawValue: 1 << 0) + static let dataContinuityVerified = Self(rawValue: 1 << 1) + static let appleBuildVerified = Self(rawValue: 1 << 2) + static let physicalDeviceVerified = Self(rawValue: 1 << 3) + static let networkPermission = Self(rawValue: 1 << 4) + static let microphonePermission = Self(rawValue: 1 << 5) + static let motionPermission = Self(rawValue: 1 << 6) + + static let releaseBaseline: Self = [ + .p0TouchVerified, + .dataContinuityVerified, + .appleBuildVerified, + .physicalDeviceVerified + ] +} + +struct VeilFeatureDescriptor: Identifiable, Hashable, Codable, Sendable { + let id: String + let packID: String + let title: String + let subtitle: String + let domain: VeilFeatureDomain + let keywords: [String] + let resourceClass: VeilFeatureResourceClass + let requiredGates: VeilFeatureGate + let conflictGroup: String? + let isExperimental: Bool +} + +struct VeilFeatureRuntimeProfile: Equatable, Sendable { + enum ThermalPressure: Int, Comparable, Sendable { + case nominal = 0 + case fair = 1 + case serious = 2 + case critical = 3 + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + } + + var isLegacyDevice: Bool + var lowPowerMode: Bool + var reduceMotion: Bool + var godModeHighQuality: Bool + var thermalPressure: ThermalPressure +} + +enum VeilFeatureRenderTier: String, Equatable, Sendable { + case staticOnly + case reduced + case standard + case high +} + +enum VeilFeatureLaunchState: String, Equatable, Sendable { + case available + case degraded + case blocked + case thermallyDeferred +} + +struct VeilFeatureLaunchDecision: Equatable, Sendable { + let state: VeilFeatureLaunchState + let renderTier: VeilFeatureRenderTier + let reason: String? + + var canLaunch: Bool { state == .available || state == .degraded } +} + +struct VeilFeatureQuery: Equatable, Sendable { + var text: String = "" + var domain: VeilFeatureDomain? + var offset: Int = 0 + var limit: Int = 40 +} + +struct VeilFeatureQueryPage: Equatable, Sendable { + let items: [VeilFeatureDescriptor] + let totalCount: Int + let offset: Int + let hasMore: Bool +} + +struct VeilFeatureRegistryAudit: Equatable, Sendable { + let featureCount: Int + let packCounts: [String: Int] + let conflictGroups: [String: [String]] +} + +enum VeilFeatureRegistryError: Error, Equatable, LocalizedError, Sendable { + case emptyID + case duplicateID(String) + case invalidPackID(String) + + var errorDescription: String? { + switch self { + case .emptyID: return "功能 ID 不能为空" + case let .duplicateID(id): return "功能 ID 重复:\(id)" + case let .invalidPackID(id): return "功能包 ID 无效:\(id)" + } + } +} + +struct VeilFeatureRegistry: Sendable { + private struct SearchEntry: Sendable { + let descriptor: VeilFeatureDescriptor + let normalizedText: String + } + + private let ordered: [VeilFeatureDescriptor] + private let byID: [String: VeilFeatureDescriptor] + private let byDomain: [VeilFeatureDomain: [VeilFeatureDescriptor]] + private let searchEntries: [SearchEntry] + + init(features: [VeilFeatureDescriptor]) throws { + var ids = Set() + for feature in features { + let id = feature.id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { throw VeilFeatureRegistryError.emptyID } + guard ids.insert(id.lowercased()).inserted else { + throw VeilFeatureRegistryError.duplicateID(feature.id) + } + guard !feature.packID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VeilFeatureRegistryError.invalidPackID(feature.id) + } + } + + ordered = features + byID = Dictionary(uniqueKeysWithValues: features.map { ($0.id.lowercased(), $0) }) + byDomain = Dictionary(grouping: features, by: \.domain) + searchEntries = features.map { feature in + let corpus = ([feature.id, feature.packID, feature.title, feature.subtitle, feature.domain.title] + feature.keywords) + .joined(separator: " ") + return SearchEntry(descriptor: feature, normalizedText: Self.normalized(corpus)) + } + } + + var count: Int { ordered.count } + + func feature(id: String) -> VeilFeatureDescriptor? { + byID[id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()] + } + + func query(_ query: VeilFeatureQuery) -> VeilFeatureQueryPage { + let needle = Self.normalized(query.text.trimmingCharacters(in: .whitespacesAndNewlines)) + let source: [VeilFeatureDescriptor] + if needle.isEmpty { + source = query.domain.flatMap { byDomain[$0] } ?? ordered + } else { + source = searchEntries.lazy.filter { entry in + (query.domain == nil || entry.descriptor.domain == query.domain) && entry.normalizedText.contains(needle) + }.map(\.descriptor) + } + + let offset = max(0, min(query.offset, source.count)) + let limit = max(1, min(query.limit, 100)) + let end = min(source.count, offset + limit) + return VeilFeatureQueryPage( + items: Array(source[offset.. VeilFeatureLaunchDecision { + guard let feature = feature(id: featureID) else { + return VeilFeatureLaunchDecision(state: .blocked, renderTier: .staticOnly, reason: "功能未注册") + } + + let missing = feature.requiredGates.subtracting(passedGates) + guard missing.isEmpty else { + return VeilFeatureLaunchDecision( + state: .blocked, + renderTier: .staticOnly, + reason: "仍需验证:\(Self.gateNames(missing).joined(separator: "、"))" + ) + } + + if profile.thermalPressure == .critical, feature.resourceClass == .renderHeavy { + return VeilFeatureLaunchDecision(state: .thermallyDeferred, renderTier: .staticOnly, reason: "设备温度过高,重渲染功能暂缓") + } + + if profile.godModeHighQuality { + return VeilFeatureLaunchDecision(state: .available, renderTier: .high, reason: "上帝模式强制高质量") + } + + if profile.reduceMotion { + return VeilFeatureLaunchDecision(state: .degraded, renderTier: .staticOnly, reason: "遵循减少动态效果") + } + + if profile.isLegacyDevice || profile.lowPowerMode || profile.thermalPressure >= .serious { + let tier: VeilFeatureRenderTier = feature.resourceClass == .featherweight ? .standard : .reduced + return VeilFeatureLaunchDecision(state: .degraded, renderTier: tier, reason: "使用兼容性能档") + } + + return VeilFeatureLaunchDecision(state: .available, renderTier: feature.resourceClass == .renderHeavy ? .high : .standard, reason: nil) + } + + func audit() -> VeilFeatureRegistryAudit { + let packCounts = Dictionary(grouping: ordered, by: \.packID).mapValues(\.count) + let conflicts = Dictionary(grouping: ordered.compactMap { feature -> (String, String)? in + feature.conflictGroup.map { ($0, feature.packID) } + }, by: \.0).mapValues { values in + Array(Set(values.map(\.1))).sorted() + }.filter { $0.value.count > 1 } + return VeilFeatureRegistryAudit(featureCount: ordered.count, packCounts: packCounts, conflictGroups: conflicts) + } + + private static func normalized(_ text: String) -> String { + text.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "zh_Hans_CN") + ) + } + + private static func gateNames(_ gates: VeilFeatureGate) -> [String] { + var names: [String] = [] + if gates.contains(.p0TouchVerified) { names.append("触控 P0") } + if gates.contains(.dataContinuityVerified) { names.append("数据连续性") } + if gates.contains(.appleBuildVerified) { names.append("Apple 构建") } + if gates.contains(.physicalDeviceVerified) { names.append("真机") } + if gates.contains(.networkPermission) { names.append("网络权限") } + if gates.contains(.microphonePermission) { names.append("麦克风权限") } + if gates.contains(.motionPermission) { names.append("运动传感器权限") } + return names + } +} diff --git a/VeilLink/Core/VeilHTMLImportPolicy.swift b/VeilLink/Core/VeilHTMLImportPolicy.swift new file mode 100644 index 00000000..814059da --- /dev/null +++ b/VeilLink/Core/VeilHTMLImportPolicy.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Offline-import limits for a resource-constrained iOS 15 device. No network I/O. +enum VeilHTMLImportPolicy { + static let maximumSourceBytes = 512 * 1024 + static let maximumOutputCharacters = 100_000 + static let maximumLinks = 40 + static let maximumSections = 400 + static let maximumSearchResults = 100 + + static func decode(_ data: Data) throws -> String { + guard !data.isEmpty else { throw VeilHTMLImportError.emptyFile } + guard data.count <= maximumSourceBytes else { throw VeilHTMLImportError.fileTooLarge } + if data.starts(with: [0xEF, 0xBB, 0xBF]) { + guard let result = String(data: data.dropFirst(3), encoding: .utf8) else { + throw VeilHTMLImportError.unsupportedEncoding + } + return result + } + if data.starts(with: [0xFF, 0xFE]) { + guard let value = String(data: data, encoding: .utf16LittleEndian) else { + throw VeilHTMLImportError.unsupportedEncoding + } + return value.replacingOccurrences(of: "\u{FEFF}", with: "") + } + if data.starts(with: [0xFE, 0xFF]) { + guard let value = String(data: data, encoding: .utf16BigEndian) else { + throw VeilHTMLImportError.unsupportedEncoding + } + return value.replacingOccurrences(of: "\u{FEFF}", with: "") + } + guard let decoded = String(data: data, encoding: .utf8) else { + throw VeilHTMLImportError.unsupportedEncoding + } + return decoded + } + + /// Only classify human-readable web links; never open or fetch them automatically. + static func safeExternalLink(_ raw: String) -> String? { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.utf8.count <= 2048, !value.isEmpty, + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let parts = URLComponents(string: value), + let scheme = parts.scheme?.lowercased(), scheme == "https" || scheme == "http", + let host = parts.host, !host.isEmpty, + parts.user == nil, parts.password == nil else { return nil } + return value + } + + static func bounded(_ text: String, limit: Int = maximumOutputCharacters) -> String { + guard limit > 0 else { return "" } + return String(text.prefix(limit)) + } + + static func isMatch(_ line: String, query: String) -> Bool { + let cleaned = query.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty || line.localizedCaseInsensitiveContains(cleaned) + } +} + +enum VeilHTMLImportError: Error, LocalizedError, Sendable { + case emptyFile + case fileTooLarge + case unsupportedEncoding + case invalidDocument + case unsupportedSource + + var errorDescription: String? { + switch self { + case .emptyFile: return "文件为空。" + case .fileTooLarge: return "HTML 文件超过 512 KiB;请先在其他工具中精简后导入。" + case .unsupportedEncoding: return "目前支持 UTF-8 和带 BOM 的 UTF-16 HTML。" + case .invalidDocument: return "无法从这个 HTML 文件提取正文。" + case .unsupportedSource: return "只接受来自系统文件选择器的本地文件。" + } + } +} + +struct VeilOfflineHTMLLink: Equatable, Sendable, Identifiable { + let label: String + let address: String + var id: String { address } +} + +struct VeilOfflineHTMLDocument: Sendable, Equatable { + let title: String + let sections: [String] + let links: [VeilOfflineHTMLLink] + let truncated: Bool + + func matchingSections(query: String) -> [String] { + Array(sections.lazy.filter { VeilHTMLImportPolicy.isMatch($0, query: query) } + .prefix(VeilHTMLImportPolicy.maximumSearchResults)) + } +} diff --git a/VeilLink/Core/VeilMissionControlEngine.swift b/VeilLink/Core/VeilMissionControlEngine.swift new file mode 100644 index 00000000..8d61310c --- /dev/null +++ b/VeilLink/Core/VeilMissionControlEngine.swift @@ -0,0 +1,360 @@ +import Foundation + +/// An isolated, deterministic analyzer. It NEVER transmits packets, reads keys, +/// migrates databases, changes runtime authority, or modifies a user's records. +enum VeilMissionFeature: String, CaseIterable, Identifiable, Sendable { + case linkHealth, transportSelection, meshRoute, alternateRoute, relayHopBudget + case packetSizing, transferRecovery, congestionControl, retryPacing, trafficFairness + case deliverySLO, storageRunway, keyReadiness, replayGuard, privacyFootprint + case thermalGuard, batteryReserve, touchOcclusion, incidentCorrelation, capabilityFallback + + var id: String { rawValue } + var title: String { + switch self { + case .linkHealth: return "链路健康评分" + case .transportSelection: return "BLE / LAN 路径决策" + case .meshRoute: return "Mesh 最优路径" + case .alternateRoute: return "独立备用路径" + case .relayHopBudget: return "中继跳数预算" + case .packetSizing: return "MTU 安全分片" + case .transferRecovery: return "断点恢复计划" + case .congestionControl: return "拥塞窗口控制" + case .retryPacing: return "自适应重试" + case .trafficFairness: return "控制消息优先级" + case .deliverySLO: return "消息交付可靠性" + case .storageRunway: return "存储空间预测" + case .keyReadiness: return "密钥与数据保护门禁" + case .replayGuard: return "重放异常检查" + case .privacyFootprint: return "遥测隐私风险" + case .thermalGuard: return "设备热负载保护" + case .batteryReserve: return "电量续航保护" + case .touchOcclusion: return "触摸遮挡检测" + case .incidentCorrelation: return "故障事件关联" + case .capabilityFallback: return "能力降级协商" + } + } + var group: String { + switch self { + case .linkHealth, .transportSelection, .meshRoute, .alternateRoute, .relayHopBudget: return "链路与路由" + case .packetSizing, .transferRecovery, .congestionControl, .retryPacing, .trafficFairness: return "传输与恢复" + case .deliverySLO, .storageRunway, .keyReadiness, .replayGuard, .privacyFootprint: return "可靠性与隐私" + case .thermalGuard, .batteryReserve, .touchOcclusion, .incidentCorrelation, .capabilityFallback: return "设备与诊断" + } + } +} + +enum VeilMissionSeverity: String, Sendable { + case normal, caution, blocked +} + +struct VeilMissionFinding: Equatable, Sendable, Identifiable { + let feature: VeilMissionFeature + let severity: VeilMissionSeverity + let headline: String + let value: String + let explanation: String + var id: String { feature.rawValue } +} + +struct VeilMissionPeer: Equatable, Sendable { + let id: String + let rssi: Int + let loss: Double // 0...1; treated as unknown if outside range + let latencyMs: Double + let bleAvailable: Bool + let lanAvailable: Bool +} + +struct VeilMissionEdge: Equatable, Sendable { + let from: String + let to: String + let cost: Double // positive cost; not geographic distance +} + +struct VeilMissionOverlay: Equatable, Sendable { + let name: String + let visible: Bool + let interceptsTouches: Bool + let decorative: Bool +} + +enum VeilMissionThermal: String, Sendable { + case nominal, fair, serious, critical +} + +struct VeilMissionSnapshot: Sendable { + var peers: [VeilMissionPeer] + var edges: [VeilMissionEdge] + var sourceNode: String + var destinationNode: String + var allowedHops: Int + var mtu: Int + var dataLength: Int + var confirmedBytes: Int + var queueDepth: Int + var congestionWindow: Int + var lossFraction: Double + var ackSamplesMs: [Double] + var attempts: Int + var messageDeliverySeconds: [Double] + var undeliveredMessages: Int + var diskFreeBytes: Int64 + var projectedDailyBytes: Int64 + var databaseFilePresent: Bool + var protectedDataAvailable: Bool + var encryptionKeyAccessible: Bool + var sequenceNumbers: [UInt64] + var rawTouchCapture: Bool + var sensitiveMetadataFields: Int + var thermal: VeilMissionThermal + var batteryFraction: Double + var lowPowerMode: Bool + var overlays: [VeilMissionOverlay] + var warningEvents: [String] + var knownCapabilities: Set + var requestedCapabilities: Set +} + +enum VeilMissionScenario: String, CaseIterable, Identifiable { + case healthy = "正常链路" + case degraded = "隔墙弱链路" + case recovery = "恢复与迁移" + var id: String { rawValue } + + /// Explicitly synthetic samples. Never represent these as live device telemetry. + var snapshot: VeilMissionSnapshot { + let degraded = self == .degraded + let recovering = self == .recovery + return VeilMissionSnapshot( + peers: [ + .init(id: "P1", rssi: degraded ? -92 : -62, loss: degraded ? 0.24 : 0.01, + latencyMs: degraded ? 450 : 72, bleAvailable: true, lanAvailable: !degraded), + .init(id: "P2", rssi: -73, loss: 0.04, latencyMs: 95, + bleAvailable: true, lanAvailable: false) + ], + edges: [ + .init(from: "self", to: "P1", cost: degraded ? 8 : 2), + .init(from: "P1", to: "target", cost: 2), + .init(from: "self", to: "P2", cost: 3), + .init(from: "P2", to: "target", cost: 4) + ], + sourceNode: "self", destinationNode: "target", allowedHops: degraded ? 1 : 3, + mtu: degraded ? 23 : 185, dataLength: 98_304, + confirmedBytes: recovering ? 65_536 : 16_384, + queueDepth: degraded ? 28 : 4, + congestionWindow: 8, lossFraction: degraded ? 0.20 : 0.01, + ackSamplesMs: degraded ? [80, 110, 470, 850] : [53, 61, 64, 75], + attempts: degraded ? 5 : 1, + messageDeliverySeconds: degraded ? [2, 4, 7, 32] : [0.4, 0.6, 0.8, 1.1], + undeliveredMessages: degraded ? 3 : 0, + diskFreeBytes: recovering ? 16_384_000 : 1_073_741_824, + projectedDailyBytes: 4_194_304, + databaseFilePresent: true, + protectedDataAvailable: true, + encryptionKeyAccessible: !recovering, + sequenceNumbers: degraded ? [9, 10, 10, 8] : [9, 10, 11, 12], + rawTouchCapture: degraded, + sensitiveMetadataFields: degraded ? 2 : 0, + thermal: degraded ? .serious : .nominal, + batteryFraction: degraded ? 0.12 : 0.68, + lowPowerMode: degraded, + overlays: [.init(name: "Decorative glow", visible: true, + interceptsTouches: degraded, decorative: true)], + warningEvents: degraded ? + ["ble.timeout", "ble.timeout", "touch.cancel", "session.mismatch", "ble.timeout"] : + ["ble.connected"], + knownCapabilities: degraded ? ["ble", "text"] : ["ble", "lan", "text", "image"], + requestedCapabilities: ["ble", "text", "image"] + ) + } +} + +enum VeilMissionControlEngine { + private static func clamp(_ x: Double, _ low: Double = 0, _ high: Double = 1) -> Double { + x.isFinite ? min(high, max(low, x)) : low + } + private static func result(_ feature: VeilMissionFeature, _ severity: VeilMissionSeverity, + _ headline: String, _ value: String, _ explanation: String) -> VeilMissionFinding { + .init(feature: feature, severity: severity, headline: headline, value: value, explanation: explanation) + } + private static func safeCount(_ n: Int) -> Int { max(0, n) } + + /// Pure graph analysis, bounded to avoid huge/invalid graph payloads. + static func shortestRoute(_ edges: [VeilMissionEdge], source: String, + destination: String, avoiding: Set = []) -> ([String], Double)? { + guard !source.isEmpty, !destination.isEmpty, + !avoiding.contains(source), !avoiding.contains(destination), + edges.count <= 1_024 else { return nil } + if source == destination { return ([source], 0) } + let valid = edges.filter { + $0.cost.isFinite && $0.cost > 0 && !$0.from.isEmpty && !$0.to.isEmpty && + !avoiding.contains($0.from) && !avoiding.contains($0.to) + } + var nodes: Set = [source, destination] + for edge in valid { nodes.insert(edge.from); nodes.insert(edge.to) } + guard nodes.count <= 256 else { return nil } + var distance: [String: Double] = [source: 0] + var previous: [String: String] = [:] + var unvisited = nodes + while !unvisited.isEmpty { + guard let current = unvisited.min(by: { + (distance[$0] ?? .infinity) < (distance[$1] ?? .infinity) + }), let currentCost = distance[current] else { break } + if current == destination { break } + unvisited.remove(current) + for edge in valid where edge.from == current && unvisited.contains(edge.to) { + let candidate = currentCost + edge.cost + if candidate < (distance[edge.to] ?? .infinity) { + distance[edge.to] = candidate + previous[edge.to] = current + } + } + } + guard let total = distance[destination], total.isFinite else { return nil } + var route = [destination] + while let before = previous[route[0]] { + route.insert(before, at: 0) + if route.count > nodes.count { return nil } + } + return route.first == source ? (route, total) : nil + } + + static func analyze(_ feature: VeilMissionFeature, snapshot s: VeilMissionSnapshot) -> VeilMissionFinding { + switch feature { + case .linkHealth: + guard let p = s.peers.first else { return result(feature, .blocked, "无可用链路", "—", "当前样本没有邻居链路数据。") } + let rssi = clamp(Double(p.rssi + 100) / 55) + let loss = clamp(p.loss) + let latency = clamp(1 - p.latencyMs / 1_000) + let score = Int((rssi * 0.45 + (1 - loss) * 0.35 + latency * 0.20) * 100) + return result(feature, score < 45 ? .blocked : score < 72 ? .caution : .normal, + "邻居 \(p.id) 链路评估", "\(score)/100", + "依据 RSSI、丢包率、延迟加权计算;仅供调度参考,不保证实际距离。") + case .transportSelection: + guard let p = s.peers.first else { return result(feature, .blocked, "未检测到邻居", "不可选", "需要可达的配对设备。") } + let selected = p.lanAvailable ? "LAN" : p.bleAvailable ? "BLE" : "无可用通道" + return result(feature, selected == "无可用通道" ? .blocked : .normal, "推荐当前通道", selected, + "LAN 可用时优先大文件,BLE 保留控制与离线回退;实际选择必须通过安全会话授权。") + case .meshRoute: + guard let route = shortestRoute(s.edges, source: s.sourceNode, destination: s.destinationNode) else { + return result(feature, .blocked, "无法规划 Mesh 路径", "无路径", "缺少可信的连通图或图数据无效。") + } + return result(feature, .normal, "最低代价路径", route.0.joined(separator: " → "), + "总代价 \(String(format: "%.1f", route.1));规划结果不代表各节点已建立加密信任。") + case .alternateRoute: + guard let primary = shortestRoute(s.edges, source: s.sourceNode, destination: s.destinationNode) else { + return result(feature, .blocked, "主路不可达", "无备线", "需要先有可达主路。") + } + let internalNodes = Set(primary.0.dropFirst().dropLast()) + guard let secondary = shortestRoute(s.edges, source: s.sourceNode, destination: s.destinationNode, avoiding: internalNodes) else { + return result(feature, .caution, "没有节点独立备线", "单路径", "主路中继失效时需要等待重新发现。") + } + return result(feature, .normal, "节点独立备用路线", secondary.0.joined(separator: " → "), + "排除主路径的中间节点重新计算;不保证物理无线链路独立。") + case .relayHopBudget: + guard let route = shortestRoute(s.edges, source: s.sourceNode, destination: s.destinationNode) else { + return result(feature, .blocked, "中继预算无法应用", "无路径", "不可使用未知路线推定成功传输。") + } + let hops = max(0, route.0.count - 1), budget = safeCount(s.allowedHops) + return result(feature, hops > budget ? .blocked : .normal, "防止无界转发", "\(hops)/\(budget) 跳", + hops > budget ? "路线超过预算,应拒绝该候选路径。" : "跳数在预算内;仍须完成去重、TTL 与权限验证。") + case .packetSizing: + let payload = max(0, s.mtu - 3) + guard payload > 0 else { return result(feature, .blocked, "MTU 无效", "0 B", "ATT 写入长度不可为负。") } + let count = (safeCount(s.dataLength) + payload - 1) / payload + return result(feature, payload < 40 ? .caution : .normal, "BLE ATT 理论载荷上限", "\(payload) B", + "约 \(count) 次 ATT 写入;真正加密分片需扣除 Wire 封装开销,绝不可直接使用该上限。") + case .transferRecovery: + let total = safeCount(s.dataLength), offset = min(total, safeCount(s.confirmedBytes)) + return result(feature, offset == total ? .normal : .caution, "断点恢复候选计划", + "\(max(0, total - offset)) B 待验证", + "从已认证检查点 \(offset) B 恢复;先核验原始密文、SHA-256 与会话连续性,不能盲信本地偏移。") + case .congestionControl: + let cwnd = max(1, min(4_096, s.congestionWindow)) + let next = clamp(s.lossFraction) > 0.08 ? max(1, cwnd / 2) : min(4_096, cwnd + 1) + return result(feature, next < cwnd ? .caution : .normal, "AIMD 发送窗口建议", "\(cwnd) → \(next)", + "丢包高则乘性减小,否则缓慢增加;属于建议值,不修改活动发送队列。") + case .retryPacing: + let step = min(10, safeCount(s.attempts)) + let seconds = min(60, 1 << step) + return result(feature, step >= 5 ? .caution : .normal, "指数退避上限", "\(seconds) s", + "建议以基础退避配合有界随机扰动;真实重连还须考虑应用前后台限制。") + case .trafficFairness: + let q = safeCount(s.queueDepth) + let reserve = max(2, min(16, (q + 3) / 4)) + return result(feature, q > 20 ? .caution : .normal, "控制面保留配额", "\(reserve) 个槽位", + "优先 ACK、握手及控制包;为其他用户流量保留发送机会,防止图片压制聊天。") + case .deliverySLO: + let samples = s.messageDeliverySeconds.filter { $0.isFinite && $0 >= 0 }.sorted() + guard !samples.isEmpty else { return result(feature, .caution, "样本不足", "—", "无法给出交付延迟百分位。") } + let index = min(samples.count - 1, Int(ceil(Double(samples.count) * 0.95)) - 1) + let p95 = samples[max(0,index)] + return result(feature, p95 > 10 || s.undeliveredMessages > 0 ? .caution : .normal, + "最近消息 P95 延迟", String(format:"%.1f s",p95), + "未送达 \(safeCount(s.undeliveredMessages)) 条;样本量 \(samples.count),小样本仅供诊断。") + case .storageRunway: + let free = max(0, s.diskFreeBytes), daily = max(0, s.projectedDailyBytes) + guard daily > 0 else { return result(feature, .normal, "无有效增长率", "—", "需要非零的历史日增长估计。") } + let days = min(Int64(100_000), free / daily) + return result(feature, days < 7 ? .caution : .normal, "预计剩余本地存储", "\(days) 天", + "只使用可用空间与日增长量作线性估计;不得自动删除或清理任何聊天数据。") + case .keyReadiness: + guard s.databaseFilePresent else { + return result(feature, .caution, "未发现数据库", "只读保护", "不得凭空创建替代数据库并覆盖旧身份。") + } + if !s.protectedDataAvailable { + return result(feature, .blocked, "受保护数据暂不可用", "拒绝打开", "等待系统解锁后重试;禁止重建密钥。") + } + if !s.encryptionKeyAccessible { + return result(feature, .blocked, "数据库存在但密钥不可读取", "拒绝重置", "保留原 SQLite 字节,检查签名/Keychain 访问组连续性。") + } + return result(feature, .normal, "数据访问前置条件具备", "可进一步验证", "这不是数据库解密成功的证明;下一步仍需验证密钥匹配及数据库完整性。") + case .replayGuard: + guard !s.sequenceNumbers.isEmpty else { return result(feature, .caution, "无序列样本", "—", "不能推断重放状态。") } + let unique = Set(s.sequenceNumbers) + let repeats = s.sequenceNumbers.count - unique.count + let regression = zip(s.sequenceNumbers, s.sequenceNumbers.dropFirst()).filter { $0.1 < $0.0 }.count + return result(feature, repeats + regression > 0 ? .caution : .normal, + "单链路序列异常", "\(repeats) 重复 / \(regression) 回退", + "仅发现异常线索;认证重放窗口由既有协议实现负责,这里不拒绝真实数据包。") + case .privacyFootprint: + let exposure = safeCount(s.sensitiveMetadataFields) + (s.rawTouchCapture ? 1 : 0) + return result(feature, exposure > 0 ? .caution : .normal, + "诊断元数据最小化", "\(exposure) 项待检查", + "关闭不必要的原始触控记录,删除敏感字段;禁止输出明文、身份密钥或精确私人数据。") + case .thermalGuard: + let cap = s.thermal == .critical ? 0 : s.thermal == .serious ? 25 : s.thermal == .fair ? 60 : 100 + return result(feature, cap <= 25 ? .caution : .normal, "视觉/计算预算建议", "\(cap)%", + "系统热状态优先于强制特效。仅为计算建议,不控制 A9/A10 Governor。") + case .batteryReserve: + let fraction = clamp(s.batteryFraction) + let budget = s.lowPowerMode || fraction < 0.15 ? 20 : fraction < 0.30 ? 55 : 100 + return result(feature, budget <= 20 ? .caution : .normal, + "后台任务建议预算", "\(budget)%", + "依据电量和低电量模式降低非必要任务;不承诺绕过 iOS 后台调度。") + case .touchOcclusion: + let risky = s.overlays.filter { $0.visible && $0.decorative && $0.interceptsTouches } + return result(feature, risky.isEmpty ? .normal : .caution, + "不应接收触摸的装饰层", "\(risky.count) 处", + risky.isEmpty ? "样本未发现装饰层触摸截获。" : + "候选:\(risky.map(\.name).joined(separator: "、"))。需要真机命中路径验证,不直接更改视图层级。") + case .incidentCorrelation: + let group = Dictionary(grouping: s.warningEvents, by: { $0 }) + let sorted = group.map { ($0.key, $0.value.count) } + .sorted { $0.1 == $1.1 ? $0.0 < $1.0 : $0.1 > $1.1 } + guard let top = sorted.first else { return result(feature, .normal, "没有警告样本", "0", "暂无可归类的事件。") } + return result(feature, top.1 >= 3 ? .caution : .normal, + "最高频异常事件", top.0, + "发生 \(top.1) 次,合计 \(s.warningEvents.count) 条;同名计数不代表已证明因果关系。") + case .capabilityFallback: + let absent = s.requestedCapabilities.subtracting(s.knownCapabilities) + if absent.isEmpty { return result(feature, .normal, "请求能力齐全", "全部可用", "实际调用前仍需实时 OS 权限与安全会话检查。") } + return result(feature, .caution, "需要功能降级", "\(absent.count) 项", + "缺失:\(absent.sorted().joined(separator: "、"))。保持文本/BLE 等现有可用能力,不伪造授权。") + } + } + + static func analyzeAll(_ snapshot: VeilMissionSnapshot) -> [VeilMissionFinding] { + VeilMissionFeature.allCases.map { analyze($0, snapshot: snapshot) } + } +} diff --git a/VeilLink/Core/VeilOfflineHTMLImporter.swift b/VeilLink/Core/VeilOfflineHTMLImporter.swift new file mode 100644 index 00000000..04af285a --- /dev/null +++ b/VeilLink/Core/VeilOfflineHTMLImporter.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftSoup + +/// Read-only HTML importer. Never uses WKWebView or executes HTML scripts. +/// Import is deliberately initiated from a detached task by the SwiftUI caller. +enum VeilOfflineHTMLImporter { + static func load(fileURL: URL) throws -> VeilOfflineHTMLDocument { + guard fileURL.isFileURL else { throw VeilHTMLImportError.unsupportedSource } + let authorized = fileURL.startAccessingSecurityScopedResource() + defer { if authorized { fileURL.stopAccessingSecurityScopedResource() } } + if let size = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size]) as? NSNumber, + size.int64Value > Int64(VeilHTMLImportPolicy.maximumSourceBytes) { + throw VeilHTMLImportError.fileTooLarge + } + let raw = try Data(contentsOf: fileURL) + return try parse(raw) + } + + static func parse(_ input: Data) throws -> VeilOfflineHTMLDocument { + let decoded = try VeilHTMLImportPolicy.decode(input) + let dom = try SwiftSoup.parse(decoded) + let rawTitle = try dom.title().trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove non-content nodes *before* text extraction. Scripts never execute. + try dom.select("script,style,noscript,template,iframe,object,embed,canvas,svg,form,nav,footer,header").remove() + guard let body = dom.body() else { throw VeilHTMLImportError.invalidDocument } + + var sections: [String] = [] + sections.reserveCapacity(64) + var remaining = VeilHTMLImportPolicy.maximumOutputCharacters + var truncated = false + for element in try body.select("h1,h2,h3,h4,p,li,blockquote,pre") { + if sections.count >= VeilHTMLImportPolicy.maximumSections { truncated = true; break } + let value = try element.text().trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != sections.last else { continue } + if value.count > remaining { + if remaining > 0 { sections.append(VeilHTMLImportPolicy.bounded(value, limit: remaining)) } + truncated = true + remaining = 0 + break + } + sections.append(value) + remaining -= value.count + } + if sections.isEmpty { + let text = try body.text() + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VeilHTMLImportError.invalidDocument + } + sections.append(VeilHTMLImportPolicy.bounded(text)) + truncated = text.count > VeilHTMLImportPolicy.maximumOutputCharacters + } + + var links: [VeilOfflineHTMLLink] = [] + var uniqueURLs = Set() + for element in try body.select("a[href]") { + if links.count >= VeilHTMLImportPolicy.maximumLinks { truncated = true; break } + guard let safe = VeilHTMLImportPolicy.safeExternalLink(try element.attr("href")), + uniqueURLs.insert(safe).inserted else { continue } + let rawLabel = try element.text().trimmingCharacters(in: .whitespacesAndNewlines) + links.append(.init(label: VeilHTMLImportPolicy.bounded(rawLabel.isEmpty ? safe : rawLabel, limit: 90), address: safe)) + } + return VeilOfflineHTMLDocument( + title: VeilHTMLImportPolicy.bounded(rawTitle.isEmpty ? "无标题资料" : rawTitle, limit: 140), + sections: sections, + links: links, + truncated: truncated + ) + } +} diff --git a/VeilLink/Core/VeilPairCameraInputGate.swift b/VeilLink/Core/VeilPairCameraInputGate.swift new file mode 100644 index 00000000..a0934fb8 --- /dev/null +++ b/VeilLink/Core/VeilPairCameraInputGate.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Input-shape triage only. Cryptographic, expiry and trust checks remain in +/// VeilRemotePairQRCodeCodec.decode and VeilRemotePairingCoordinator. +enum VeilPairCameraInputGate { + static let prefix = "veillink://pair?v=1&d=" + static let maximumUTF8Bytes = 4_096 + + static func accepts(_ candidate: String) -> Bool { + guard candidate.hasPrefix(prefix), + candidate.utf8.count >= prefix.utf8.count + 8, + candidate.utf8.count <= maximumUTF8Bytes else { return false } + + // RemotePairingQRCodeCodec.encode uses unpadded base64url. + let encoded = candidate.dropFirst(prefix.count) + return encoded.utf8.allSatisfy { byte in + (65...90).contains(byte) || (97...122).contains(byte) || + (48...57).contains(byte) || byte == 45 || byte == 95 + } + } +} diff --git a/VeilLink/Core/VeilProductivityEngine.swift b/VeilLink/Core/VeilProductivityEngine.swift new file mode 100644 index 00000000..58b8d542 --- /dev/null +++ b/VeilLink/Core/VeilProductivityEngine.swift @@ -0,0 +1,283 @@ +import Foundation + +enum VeilProductivityError: Error, Equatable, LocalizedError, Sendable { + case templateTooLarge + case malformedPlaceholder + case missingVariables([String]) + case diffTooLarge + case invalidTimeZone(String) + case noParticipants + case tooManyParticipants + case invalidParticipantHours(String) + case invalidImportLimit + + var errorDescription: String? { + switch self { + case .templateTooLarge: return "内容过长,请缩短后重试" + case .malformedPlaceholder: return "模板占位符格式不完整" + case let .missingVariables(names): return "仍缺少:\(names.joined(separator: "、"))" + case .diffTooLarge: return "文本差异分析最多支持每侧 400 行、64 KB" + case let .invalidTimeZone(identifier): return "未知时区:\(identifier)" + case .noParticipants: return "至少需要一个协作时区" + case .tooManyParticipants: return "一次最多规划 24 个协作时区" + case let .invalidParticipantHours(label): return "工作时间无效:\(label)" + case .invalidImportLimit: return "导入限制必须大于零" + } + } +} + +struct VeilTemplateAnalysis: Equatable, Sendable { + let variables: [String] + let malformed: Bool +} + +enum VeilDiffKind: String, Equatable, Sendable { + case unchanged + case added + case removed +} + +struct VeilDiffLine: Equatable, Sendable { + let kind: VeilDiffKind + let text: String +} + +struct VeilMergeResult: Equatable, Sendable { + let text: String + let hasConflict: Bool +} + +struct VeilTimeZoneParticipant: Equatable, Sendable { + let label: String + let timeZoneIdentifier: String + let preferredStartHour: Int + let preferredEndHour: Int +} + +struct VeilMeetingSlot: Equatable, Sendable { + let date: Date + let comfortableParticipantCount: Int + let totalParticipantCount: Int + let localTimes: [String] +} + +struct VeilImportItem: Equatable, Sendable { + let name: String + let byteCount: Int64 +} + +enum VeilImportSeverity: String, Equatable, Sendable { + case warning + case blocking +} + +struct VeilImportIssue: Equatable, Sendable { + let itemName: String? + let severity: VeilImportSeverity + let message: String +} + +struct VeilImportPreflight: Equatable, Sendable { + let itemCount: Int + let totalBytes: Int64 + let issues: [VeilImportIssue] + + var canImport: Bool { !issues.contains { $0.severity == .blocking } } +} + +enum VeilProductivityEngine { + private static let maxTextBytes = 65_536 + private static let maxDiffLines = 400 + + static func analyzeTemplate(_ template: String) throws -> VeilTemplateAnalysis { + guard template.utf8.count <= maxTextBytes else { throw VeilProductivityError.templateTooLarge } + var variables: [String] = [] + var seen = Set() + var cursor = template.startIndex + var malformed = false + + while let open = template.range(of: "{{", range: cursor.. String { + let analysis = try analyzeTemplate(template) + guard !analysis.malformed else { throw VeilProductivityError.malformedPlaceholder } + let missing = analysis.variables.filter { values[$0]?.isEmpty != false } + guard missing.isEmpty else { throw VeilProductivityError.missingVariables(missing) } + var output = "" + var cursor = template.startIndex + while let open = template.range(of: "{{", range: cursor.. [VeilDiffLine] { + guard old.utf8.count <= maxTextBytes, new.utf8.count <= maxTextBytes else { + throw VeilProductivityError.diffTooLarge + } + let lhs = lines(old) + let rhs = lines(new) + guard lhs.count <= maxDiffLines, rhs.count <= maxDiffLines else { + throw VeilProductivityError.diffTooLarge + } + var lcs = Array(repeating: Array(repeating: 0, count: rhs.count + 1), count: lhs.count + 1) + if !lhs.isEmpty, !rhs.isEmpty { + for i in stride(from: lhs.count - 1, through: 0, by: -1) { + for j in stride(from: rhs.count - 1, through: 0, by: -1) { + lcs[i][j] = lhs[i] == rhs[j] ? lcs[i + 1][j + 1] + 1 : max(lcs[i + 1][j], lcs[i][j + 1]) + } + } + } + var result: [VeilDiffLine] = [] + var i = 0 + var j = 0 + while i < lhs.count || j < rhs.count { + if i < lhs.count, j < rhs.count, lhs[i] == rhs[j] { + result.append(VeilDiffLine(kind: .unchanged, text: lhs[i])); i += 1; j += 1 + } else if j < rhs.count, i == lhs.count || lcs[i][j + 1] >= lcs[i + 1][j] { + result.append(VeilDiffLine(kind: .added, text: rhs[j])); j += 1 + } else { + result.append(VeilDiffLine(kind: .removed, text: lhs[i])); i += 1 + } + } + return result + } + + static func threeWayMerge(base: String, local: String, remote: String) throws -> VeilMergeResult { + guard [base, local, remote].allSatisfy({ $0.utf8.count <= maxTextBytes }) else { + throw VeilProductivityError.diffTooLarge + } + if local == remote { return VeilMergeResult(text: local, hasConflict: false) } + if local == base { return VeilMergeResult(text: remote, hasConflict: false) } + if remote == base { return VeilMergeResult(text: local, hasConflict: false) } + let text = "<<<<<<< 本机\n\(local)\n||||||| 基线\n\(base)\n=======\n\(remote)\n>>>>>>> 对端" + return VeilMergeResult(text: text, hasConflict: true) + } + + static func meetingSlots( + from start: Date, + participants: [VeilTimeZoneParticipant], + days: Int = 3, + limit: Int = 8 + ) throws -> [VeilMeetingSlot] { + guard !participants.isEmpty else { throw VeilProductivityError.noParticipants } + guard participants.count <= 24 else { throw VeilProductivityError.tooManyParticipants } + let resolved: [(VeilTimeZoneParticipant, TimeZone, DateFormatter)] = try participants.map { participant in + guard (0...23).contains(participant.preferredStartHour), + (1...24).contains(participant.preferredEndHour), + participant.preferredStartHour < participant.preferredEndHour else { + throw VeilProductivityError.invalidParticipantHours(participant.label) + } + guard let zone = TimeZone(identifier: participant.timeZoneIdentifier) else { + throw VeilProductivityError.invalidTimeZone(participant.timeZoneIdentifier) + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = zone + formatter.dateFormat = "MM-dd HH:mm" + return (participant, zone, formatter) + } + let horizon = max(1, min(days, 7)) * 48 + let count = max(1, min(limit, 24)) + let aligned = Date(timeIntervalSince1970: ceil(start.timeIntervalSince1970 / 1_800) * 1_800) + var candidates: [VeilMeetingSlot] = [] + for offset in 0.. $1.comfortableParticipantCount + } + return $0.date < $1.date + }.prefix(count).map { $0 } + } + + static func importPreflight( + items: [VeilImportItem], + maximumItems: Int = 500, + maximumTotalBytes: Int64 = 2_147_483_648, + allowedExtensions: Set = [] + ) throws -> VeilImportPreflight { + guard maximumItems > 0, maximumTotalBytes > 0 else { throw VeilProductivityError.invalidImportLimit } + var issues: [VeilImportIssue] = [] + var seen = Set() + var total: Int64 = 0 + let normalizedExtensions = Set(allowedExtensions.map { $0.lowercased() }) + if items.count > maximumItems { + issues.append(VeilImportIssue(itemName: nil, severity: .blocking, message: "文件数超过 \(maximumItems) 个限制")) + } + for item in items { + let name = item.name.precomposedStringWithCanonicalMapping + let key = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) + if name.isEmpty || name == "." || name == ".." || name.contains("/") || name.contains("\\") || name.contains("\0") { + issues.append(VeilImportIssue(itemName: item.name, severity: .blocking, message: "文件名不安全")) + } + if !seen.insert(key).inserted { + issues.append(VeilImportIssue(itemName: item.name, severity: .blocking, message: "规范化后文件名重复")) + } + if item.byteCount < 0 { + issues.append(VeilImportIssue(itemName: item.name, severity: .blocking, message: "文件大小无效")) + } else if item.byteCount == 0 { + issues.append(VeilImportIssue(itemName: item.name, severity: .warning, message: "空文件")) + } else if total > Int64.max - item.byteCount { + issues.append(VeilImportIssue(itemName: nil, severity: .blocking, message: "总大小计算溢出")) + } else { + total += item.byteCount + } + if !normalizedExtensions.isEmpty { + let ext = (name as NSString).pathExtension.lowercased() + if !normalizedExtensions.contains(ext) { + issues.append(VeilImportIssue(itemName: item.name, severity: .blocking, message: "不允许的文件类型")) + } + } + } + if total > maximumTotalBytes { + issues.append(VeilImportIssue(itemName: nil, severity: .blocking, message: "总大小超过限制")) + } + return VeilImportPreflight(itemCount: items.count, totalBytes: total, issues: issues) + } + + private static func lines(_ text: String) -> [String] { + text.isEmpty ? [] : text.components(separatedBy: .newlines) + } +} diff --git a/VeilLink/Core/VeilRareLabEngine.swift b/VeilLink/Core/VeilRareLabEngine.swift new file mode 100644 index 00000000..ad43c3cc --- /dev/null +++ b/VeilLink/Core/VeilRareLabEngine.swift @@ -0,0 +1,260 @@ +import Foundation + +// VeilLink RareLab: pure, offline, manual-input analysis. No I/O, storage, +// key access, transport mutation, location collection, or network connection. +// This is deliberately independent of Mission Control R1 (20 features). + +enum VeilRareLabError: Error, LocalizedError { + case badInput(String) + var errorDescription: String? { + switch self { case .badInput(let message): return message } + } +} + +struct VeilRareTool: Identifiable, Equatable { + let id: Int + let symbol: String + let title: String + let category: String + let detail: String +} + +enum VeilRareLab { + static let maxInputBytes = 64_000 + static let catalog: [VeilRareTool] = [ + .init(id: 1, symbol: "scanGaps", title: "扫描空窗探测", category: "无线与 Mesh", detail: "观察邻居扫描间隙超过中位数的情况"), + .init(id: 2, symbol: "robustRSSI", title: "鲁棒 RSSI 离群判别", category: "无线与 Mesh", detail: "以中位数及 MAD 识别信号离群值"), + .init(id: 3, symbol: "shadowStreak", title: "射频阴影连续段", category: "无线与 Mesh", detail: "测量弱信号持续最长的采样段"), + .init(id: 4, symbol: "peerChurn", title: "邻居发现抖动", category: "无线与 Mesh", detail: "统计扫描样本中的新增和消失趋势"), + .init(id: 5, symbol: "advertCollision", title: "广告周期碰撞窗口", category: "无线与 Mesh", detail: "估算两广告周期发生相位重合的间隔"), + .init(id: 6, symbol: "channelEntropy", title: "广告信道多样性", category: "无线与 Mesh", detail: "计算 37/38/39 信道分布熵"), + .init(id: 7, symbol: "hysteresis", title: "链路防抖切换模拟", category: "无线与 Mesh", detail: "通过进入与退出门槛抑制切换震荡"), + .init(id: 8, symbol: "articulation", title: "Mesh 网络割点", category: "无线与 Mesh", detail: "识别失效可能拆散拓扑的关键中继"), + .init(id: 9, symbol: "bridgeEdges", title: "Mesh 关键桥接边", category: "无线与 Mesh", detail: "识别删去后会分裂连通性的关键链路"), + .init(id: 10, symbol: "hopReach", title: "跳数内可达率", category: "无线与 Mesh", detail: "在指定跳数预算内计算可达节点"), + .init(id: 11, symbol: "lossBursts", title: "分片连续丢失簇", category: "传输与恢复", detail: "识别分片空缺的最长连续区间"), + .init(id: 12, symbol: "ackHoles", title: "确认序列空洞", category: "传输与恢复", detail: "计算 ACK 序列中不存在的编号"), + .init(id: 13, symbol: "reorder", title: "乱序倒置密度", category: "传输与恢复", detail: "测量到达顺序中的逆序对"), + .init(id: 14, symbol: "retryAmplification", title: "重传放大因子", category: "传输与恢复", detail: "比较实际发送与唯一逻辑分片数量"), + .init(id: 15, symbol: "windowPressure", title: "接收窗口超订阅", category: "传输与恢复", detail: "判断等待队列是否超过接收窗口"), + .init(id: 16, symbol: "checkpointOrder", title: "检查点单调性", category: "传输与恢复", detail: "识别进度回退或重复"), + .init(id: 17, symbol: "throughputCollapse", title: "吞吐坍塌比", category: "传输与恢复", detail: "比较头尾吞吐窗口的下降程度"), + .init(id: 18, symbol: "idleWatchdog", title: "链路静默预算", category: "传输与恢复", detail: "根据最后消息时间判断是否超过静默门槛"), + .init(id: 19, symbol: "fairQuota", title: "优先级配额公平性", category: "传输与恢复", detail: "分析各业务槽位比例与最小保障"), + .init(id: 20, symbol: "parityRepair", title: "奇偶分片修复门槛", category: "传输与恢复", detail: "判断每一组 XOR 校验能否覆盖单分片缺失"), + .init(id: 21, symbol: "trustReciprocity", title: "非对称信任关系", category: "身份与隐私", detail: "发现单向声明而没有回执的信任边"), + .init(id: 22, symbol: "trustFanout", title: "超常信任扩散", category: "身份与隐私", detail: "检测单节点信任关联爆发"), + .init(id: 23, symbol: "signerContinuity", title: "签名访问组连续性", category: "身份与隐私", detail: "对比旧版与新版非秘密签名命名空间"), + .init(id: 24, symbol: "fingerprintDrift", title: "身份公钥指纹变更", category: "身份与隐私", detail: "识别同一设备公开指纹历史漂移"), + .init(id: 25, symbol: "qrReuse", title: "一次性二维码复用", category: "身份与隐私", detail: "检测已消费能力摘要被重复使用"), + .init(id: 26, symbol: "clockSkew", title: "多端时钟偏移", category: "身份与隐私", detail: "估算两端事件时间戳的偏差"), + .init(id: 27, symbol: "nonceDuplicates", title: "随机数摘要重复", category: "身份与隐私", detail: "发现样本摘要重复,提示进一步认证检查"), + .init(id: 28, symbol: "capabilityDowngrade", title: "能力宣告降级", category: "身份与隐私", detail: "比较认证前后的特性位集合"), + .init(id: 29, symbol: "metadataInventory", title: "诊断元数据清单", category: "身份与隐私", detail: "按字段名检查不宜导出的敏感项"), + .init(id: 30, symbol: "consentContinuity", title: "签署版本连续性", category: "身份与隐私", detail: "检查当前协议版本与本地签署版本一致性"), + .init(id: 31, symbol: "touchBalance", title: "触摸按下抬起失衡", category: "触控与取证", detail: "核对按下、结束、取消事件完整性"), + .init(id: 32, symbol: "cancelRate", title: "手势中断率", category: "触控与取证", detail: "计算已开始触摸被系统取消的比例"), + .init(id: 33, symbol: "deadzoneGrid", title: "触控死区网格", category: "触控与取证", detail: "按用户提供的网格命中情况定位弱响应区"), + .init(id: 34, symbol: "occlusion", title: "残留透明遮挡", category: "触控与取证", detail: "识别仍然接受触摸的可见装饰层"), + .init(id: 35, symbol: "edgeGesture", title: "边缘滑动冲突", category: "触控与取证", detail: "测量边缘启动手势的失败比例"), + .init(id: 36, symbol: "themeLag", title: "主题切换响应延迟", category: "触控与取证", detail: "比较主题切换前后点击响应中位数"), + .init(id: 37, symbol: "focusLoops", title: "输入框焦点循环", category: "触控与取证", detail: "统计焦点反复获得与失去的次数"), + .init(id: 38, symbol: "frameHitches", title: "主线程卡顿簇", category: "触控与取证", detail: "计算超帧预算的最长连续卡顿"), + .init(id: 39, symbol: "thermalCoupling", title: "热状态与卡顿共变", category: "触控与取证", detail: "对齐温度级别和帧耗时,检查共同变化"), + .init(id: 40, symbol: "faultCoincidence", title: "故障窗口共现", category: "触控与取证", detail: "以事件时间窗口检测故障的相邻出现"), + ] + + /// Only anonymous, bounded numeric and technical examples are included. + /// Do not paste identity keys, message plaintext, credentials or personal metadata. + static func sample(for id: Int) -> String { + switch id { + case 1...10: + return #"{"timeMs":[0,140,280,900,1050,1200],"rssi":[-58,-62,-97,-95,-64,-60],"peers":["A","A","B","A","C","B"],"intervals":[120,180],"channels":[37,37,38,39,38,37],"edges":[[0,1],[1,2],[2,3],[0,4],[4,2]],"thresholdIn":-65,"thresholdOut":-82,"hopBudget":2}"# + case 11...20: + return #"{"expected":[0,1,2,3,4,5,6,7,8,9],"received":[0,1,4,5,8,9],"acks":[10,12,15],"arrival":[1,3,2,5,4],"sent":[0,1,1,2,3,3,3],"window":3,"pending":7,"checkpoints":[0,1024,2048,2048,3072],"rates":[90,88,82,36,29,20],"nowMs":9000,"lastSeenMs":2500,"idleLimitMs":3000,"quotas":[4,1,1,0],"missingPerGroup":[0,1,2,1]}"# + case 21...30: + return #"{"trustEdges":[[0,1],[1,0],[0,2],[0,3],[0,4]],"oldGroup":"TEAM_A.shared","newGroup":"TEAM_B.shared","fingerprints":["p1","p1","p2"],"tokens":["q1","q2","q1"],"clockA":[1000,2000,3000],"clockB":[1140,2140,3140],"nonces":["x1","x2","x1"],"previousBits":7,"currentBits":3,"metadataFields":["event","screen","message_plaintext","rssi"],"consentedVersion":4,"requiredVersion":5}"# + default: + return #"{"phases":["down","move","cancel","down","up"],"touchGrid":[[10,8,7],[9,0,2],[9,8,8]],"overlays":[{"name":"glow","decorative":true,"visible":true,"hittable":true}],"edgeAttempts":12,"edgeFailed":5,"beforeMs":[22,24,28,30],"afterMs":[44,90,87,45],"focusEvents":["begin","end","begin","end","begin"],"frameMs":[16,33,88,100,18,70],"thermalLevels":[0,1,2,3,1,2],"events":[[100,"touch.cancel"],[400,"ui.missed"],[1200,"ble.timeout"],[1400,"touch.cancel"]],"faultWindowMs":450}"# + } + } + + static func run(id: Int, json: String) throws -> String { + guard (1...40).contains(id) else { throw VeilRareLabError.badInput("未知运算器编号") } + guard json.utf8.count <= maxInputBytes else { throw VeilRareLabError.badInput("样本大于 64 KB") } + guard let decoded = try? JSONSerialization.jsonObject(with: Data(json.utf8)), + let x = decoded as? [String: Any] else { + throw VeilRareLabError.badInput("请输入 JSON 对象,不读取真实设备数据") + } + return try execute(id: id, x: x) + } + + // Bounded, data-only extraction helpers. Invalid sample types fail closed. + private static func nums(_ x: [String: Any], _ key: String, max: Int = 4096) throws -> [Double] { + guard let values = x[key] as? [NSNumber], values.count <= max, + values.allSatisfy({ $0.doubleValue.isFinite && abs($0.doubleValue) <= 1e12 }) else { + throw VeilRareLabError.badInput("字段 \(key) 必须是有限数值数组(最多 \(max) 项)") + } + return values.map(\.doubleValue) + } + private static func ints(_ x: [String: Any], _ key: String, max: Int = 4096) throws -> [Int] { + let n = try nums(x, key, max: max) + guard n.allSatisfy({ $0.rounded() == $0 && $0 >= -1e9 && $0 <= 1e9 }) else { + throw VeilRareLabError.badInput("字段 \(key) 必须是安全范围内的整数数组") + } + return n.map(Int.init) + } + private static func words(_ x: [String: Any], _ key: String, max: Int = 4096) throws -> [String] { + guard let a = x[key] as? [String], a.count <= max, a.allSatisfy({ $0.utf8.count <= 128 }) else { + throw VeilRareLabError.badInput("字段 \(key) 必须是短字符串数组") + } + return a + } + private static func number(_ x: [String: Any], _ key: String, fallback: Double = 0) -> Double { + guard let n = x[key] as? NSNumber, n.doubleValue.isFinite, abs(n.doubleValue) < 1e12 else { return fallback } + return n.doubleValue + } + private static func string(_ x: [String: Any], _ key: String) -> String { + let value = x[key] as? String ?? "" + return String(value.prefix(128)) + } + private static func pairs(_ x: [String: Any], _ key: String) throws -> [(Int, Int)] { + guard let a = x[key] as? [[NSNumber]], a.count <= 512, a.allSatisfy({ $0.count == 2 && abs($0[0].doubleValue) < 1e6 && abs($0[1].doubleValue) < 1e6 && $0[0].doubleValue.isFinite && $0[1].doubleValue.isFinite && $0[0].doubleValue.rounded() == $0[0].doubleValue && $0[1].doubleValue.rounded() == $0[1].doubleValue }) else { throw VeilRareLabError.badInput("字段 \(key) 需要 [[整数,整数],...],最多512条") } + return a.map { ($0[0].intValue, $0[1].intValue) } + } + private static func median(_ a: [Double]) -> Double { + guard !a.isEmpty else { return 0 } + let s = a.sorted(), i = s.count / 2 + return s.count % 2 == 0 ? (s[i - 1] + s[i]) / 2 : s[i] + } + private static func longest(_ a: [Bool]) -> Int { + var current = 0, longest = 0 + for ok in a { current = ok ? current + 1 : 0; longest = Swift.max(longest, current) } + return longest + } + private static func output(_ label: String, _ value: String, _ note: String) -> String { + "\(label):\(value)\n\(note)" + } + private static func pct(_ ratio: Double) -> String { + String(format: "%.1f%%", max(0, min(1, ratio.isFinite ? ratio : 0)) * 100) + } + private static func ratio(_ numerator: Int, _ denominator: Int) -> Double { + denominator > 0 ? Double(numerator) / Double(denominator) : 0 + } + private static func graph(_ x: [String: Any]) throws -> [Int: Set] { + let edges = try pairs(x, "edges") + var g: [Int: Set] = [:] + for (a,b) in edges where a != b { g[a, default: []].insert(b); g[b, default: []].insert(a) } + return g + } + private static func components(_ graph: [Int:Set], excluding vertex: Int? = nil, excludingEdge: (Int,Int)? = nil) -> Int { + let nodes = Set(graph.keys).subtracting(vertex.map { [$0] } ?? []) + var unseen = nodes, count = 0 + while let seed = unseen.first { + count += 1 + var queue = [seed] + unseen.remove(seed) + while let top = queue.popLast() { + for next in graph[top] ?? [] where next != vertex && !(excludingEdge.map { ($0.0 == top && $0.1 == next) || ($0.1 == top && $0.0 == next) } ?? false) { + if unseen.remove(next) != nil { queue.append(next) } + } + } + } + return count + } + + private static func execute(id: Int, x: [String: Any]) throws -> String { + switch id { + case 1: + let a = try nums(x, "timeMs"); guard a.count > 2 else { return output("扫描空窗", "样本不足", "需要至少三个时间戳。") } + let gaps = zip(a,a.dropFirst()).map { max(0,$1-$0) }; let m = median(gaps) + return output("异常扫描间隙", "\(gaps.filter { $0 > max(100, m * 2.5) }.count) 次", "参考中位间隔 \(Int(m)) ms;不证明无线干扰来源。") + case 2: + let a = try nums(x,"rssi"); guard a.count > 2 else { return output("鲁棒 RSSI", "样本不足", "需要至少三次观测。") } + let m = median(a), mad = median(a.map { abs($0-m) }); let count = a.filter { abs($0-m) > max(8, mad*3) }.count + return output("MAD 离群点", "\(count)/\(a.count)", "RSSI 中位数 \(Int(m)) dBm,MAD \(String(format:"%.1f",mad));仅为异常线索。") + case 3: + let a = try nums(x,"rssi"); let n = longest(a.map { $0 < -85 }); return output("射频阴影", "最长 \(n) 个采样", "连续弱 RSSI 样本,仅反映给定输入。") + case 4: + let a = try words(x,"peers"); guard a.count > 1 else { return output("邻居抖动", "样本不足", "至少两个样本。") }; let changes = zip(a,a.dropFirst()).filter { $0 != $1 }.count + return output("可见邻居切换", "\(changes) 次", "相邻样本切换率 \(pct(ratio(changes,a.count-1)));非真实断线次数。") + case 5: + let a = try ints(x,"intervals"); guard a.count==2 && a.allSatisfy({$0 > 0 && $0 <= 60000}) else {throw VeilRareLabError.badInput("intervals 必须为两个正整数毫秒")}; var aa=a[0], bb=a[1]; while bb != 0 { (aa,bb) = (bb,aa%bb) }; let lcm = a[0]/aa*a[1] + return output("理论相位重复周期", "\(lcm) ms", "假设固定周期和同相起点;实际 BLE 广播包含随机延迟。") + case 6: + let a = try ints(x,"channels"); guard !a.isEmpty else { return output("信道熵", "样本不足", "需要广告信道记录。") }; let counts = [37,38,39].map { c in a.filter{$0==c}.count }; guard counts.reduce(0,+)==a.count else {throw VeilRareLabError.badInput("仅允许信道 37、38、39")}; let entropy = counts.reduce(0.0) { value,c in let p=Double(c)/Double(a.count); return value+(p>0 ? -p*log2(p) : 0) } + return output("广告信道 Shannon 熵", String(format:"%.3f / 1.585 bit",entropy), "只说明样本分散程度,不是链路带宽。") + case 7: + let a = try nums(x,"rssi"); let thresholdIn=number(x,"thresholdIn",fallback:-65), thresholdOut=number(x,"thresholdOut",fallback:-82); guard thresholdIn > thresholdOut else {throw VeilRareLabError.badInput("进入阈值须高于退出阈值")}; var active=false,switches=0; for value in a {let next=active ? value>=thresholdOut : value>=thresholdIn; if next != active {switches+=1}; active=next} + return output("双门槛状态变更", "\(switches) 次", "减少门槛附近的抖动;不自动更换实际连接。") + case 8: + let g=try graph(x),baseline=components(g); let cut = g.keys.sorted().filter { components(g,excluding:$0)>baseline }; return output("拓扑割点",cut.isEmpty ? "无" : cut.map(String.init).joined(separator:","), "任何割点断开可能导致网络分区;仅处理无向候选图。") + case 9: + let g=try graph(x),baseline=components(g); let edges=g.flatMap { u,neighbors in neighbors.filter{$0>u}.map{(u,$0)} }; let bridges=edges.filter{components(g,excludingEdge:$0)>baseline}; return output("关键桥接边",bridges.isEmpty ? "无" : bridges.map { "\($0.0)-\($0.1)" }.joined(separator:", "),"仅拓扑层结果,不保证物理链路独立。") + case 10: + let g=try graph(x),hops=max(0,min(64,Int(number(x,"hopBudget",fallback:2)))); let root=g.keys.sorted().first ?? 0; var reached:Set=[root],frontier=[root]; for _ in 0..i && a[i]>a[j] {inversions+=1}};let denom=a.count*(a.count-1)/2;return output("乱序倒置比例",pct(ratio(inversions,denom)),"使用有界样本的两两逆序,不推断协议重放攻击。") + case 14: + let a=try ints(x,"sent");let unique=Set(a).count;let amp=unique==0 ? 0 : Double(a.count)/Double(unique);return output("发送放大因子",String(format:"%.2f×",amp),"只按逻辑分片标识计数;可能包含正常重试。") + case 15: + let pending=max(0,Int(number(x,"pending"))),window=max(1,Int(number(x,"window",fallback:1)));let over=max(0,pending-window);return output("接收窗口压测", "超额 \(over) 块", "仅提示缓冲压力,不改变 CoreBluetooth 窗口。") + case 16: + let a=try nums(x,"checkpoints");let rollback=zip(a,a.dropFirst()).filter{$1<$0}.count;let duplicate=zip(a,a.dropFirst()).filter{$1==$0}.count;return output("检查点异常", "回退 \(rollback) / 重复 \(duplicate)","恢复必须先验证认证标记;重复位置未必是错误。") + case 17: + let a=try nums(x,"rates").filter{$0>=0};guard a.count>=4 else{return output("吞吐坍塌","样本不足","至少四个速率窗口。")};let mid=a.count/2,head=median(Array(a.prefix(mid))),tail=median(Array(a.suffix(mid)));return output("近期吞吐变化",pct(head>0 ? max(0,(head-tail)/head) : 0),"头半中位数 \(Int(head)),尾半 \(Int(tail));单位取决于输入采样。") + case 18: + let now=number(x,"nowMs"),last=number(x,"lastSeenMs"),limit=number(x,"idleLimitMs",fallback:3000);guard limit>0 else{throw VeilRareLabError.badInput("静默门槛必须大于零")};let age=max(0,now-last);return output("静默监测",age>limit ? "已超时" : "窗口内", "静默 \(Int(age)) ms / 门槛 \(Int(limit)) ms,不会发送心跳。") + case 19: + let q=try ints(x,"quotas");guard !q.isEmpty&&q.allSatisfy({$0>=0}) else {throw VeilRareLabError.badInput("quotas 必须非负且非空")};let total=q.reduce(0,+);let starved=q.filter{$0==0}.count;return output("业务配额饥饿", "\(starved) 类零槽位", "总预算 \(total) 槽,静态诊断并非实际调度器。") + case 20: + let a=try ints(x,"missingPerGroup");guard a.allSatisfy({$0>=0}) else{throw VeilRareLabError.badInput("缺失数不得为负")};let repairable=a.filter{$0==1}.count;let failed=a.filter{$0>1}.count;return output("单 XOR 校验可修复组", "\(repairable) 组,无法覆盖 \(failed) 组", "只有假定每组存在且已验证单独 XOR 校验分片时才成立。") + case 21: + let edges=try pairs(x,"trustEdges"),set=Set(edges.map{"\($0.0)>\($0.1)"});let asymmetric=edges.filter{!set.contains("\($0.1)>\($0.0)")};return output("单向信任声明", "\(asymmetric.count) 条", "双向边不等于已完成身份验证,不自动提升任何信任。") + case 22: + let edges=try pairs(x,"trustEdges"),groups=Dictionary(grouping:edges,by:{$0.0});let out=groups.mapValues{Set($0.map{$0.1}).count};let maxFan=out.values.max() ?? 0;return output("最大信任出度", "\(maxFan) 条", "超常扩散仅提示人工核对,不构成恶意节点判定。") + case 23: + let a=string(x,"oldGroup"),b=string(x,"newGroup");guard !a.isEmpty && !b.isEmpty else{throw VeilRareLabError.badInput("需要旧版与新版的非秘密访问组标签")};return output("签名命名空间连续性",a==b ? "一致" : "发生变更", "变更可能影响旧 Keychain 可读性;不能据此重建数据库密钥。") + case 24: + let a=try words(x,"fingerprints");let unique=Set(a);return output("公开指纹版本", "\(unique.count) 种", "只接受已脱敏的公开指纹别名,不能自动判为密钥被替换。") + case 25: + let a=try words(x,"tokens");let repeats=a.count-Set(a).count;return output("一次性能力复用", "\(repeats) 次重复", "输入须为不可反推的测试摘要,禁止上传真实二维码密钥。") + case 26: + let a=try nums(x,"clockA"),b=try nums(x,"clockB");guard a.count==b.count else{throw VeilRareLabError.badInput("两端必须有等量对齐时间戳")};let offsets=zip(a,b).map{$1-$0};return output("中位时钟偏移", "\(Int(median(offsets))) ms", "只估计对齐样本偏移,网络时延及抖动尚未消除。") + case 27: + let a=try words(x,"nonces");return output("样本摘要重复", "\(a.count-Set(a).count) 次", "只分析脱敏摘要,不是协议层完整随机数安全证明。") + case 28: + let old=Int(number(x,"previousBits")),new=Int(number(x,"currentBits"));guard old>=0&&new>=0 else{throw VeilRareLabError.badInput("特性位必须非负")};let lost=old & ~new;return output("能力位回退", "0x\(String(lost,radix:16))", "可能由版本协商产生,不能自动当成攻击。") + case 29: + let a=try words(x,"metadataFields");let keys=["password","token","secret","plaintext","private","raw_body"];let bad=a.filter{f in keys.contains{f.lowercased().contains($0)}};return output("敏感元数据字段名", "\(bad.count) 项疑似", "只按字段名检查;不会访问或展示字段值。") + case 30: + let signed=Int(number(x,"consentedVersion",fallback:-1)),required=Int(number(x,"requiredVersion",fallback:-1));guard required>=0 else{throw VeilRareLabError.badInput("requiredVersion 缺失")};return output("协议版本签署门禁",signed==required ? "版本一致" : "必须重新确认", "仅分析版本号;真实签署与有效性由现有 Legal Gate 验证。") + case 31: + let a=try words(x,"phases");let started=a.filter{$0=="down"}.count,ended=a.filter{$0=="up"}.count,cancelled=a.filter{$0=="cancel"}.count;return output("触控序列平衡", "开始 \(started) / 结束 \(ended) / 取消 \(cancelled)","未归还事件估计 \(max(0,started-ended-cancelled)),未追踪触控ID时只能作为线索。") + case 32: + let a=try words(x,"phases");let started=a.filter{$0=="down"}.count,cancelled=a.filter{$0=="cancel"}.count;return output("触摸取消率",pct(ratio(cancelled,started)),"取消可能来自系统导航或通知,不证明控件 Bug。") + case 33: + guard let rows=x["touchGrid"] as? [[NSNumber]],rows.count<=32,rows.allSatisfy({$0.count<=32 && $0.allSatisfy{$0.intValue>=0}}) else{throw VeilRareLabError.badInput("touchGrid 必须为非负网格")};let minHits=rows.flatMap{$0.map(\.intValue)}.min() ?? 0;let zones=rows.enumerated().flatMap{r,row in row.enumerated().compactMap{c,n in n.intValue<=minHits ? "\(r),\(c)" : nil}};return output("最低响应热区",zones.prefix(12).joined(separator:"; "),"最低采样数 \(minHits),0可能表示无人点击而非真实死区。") + case 34: + guard let overlays=x["overlays"] as? [[String:Any]],overlays.count<=128 else{throw VeilRareLabError.badInput("overlays 必须为对象数组")};let risky=overlays.filter{($0["decorative"] as? Bool ?? false)&&($0["visible"] as? Bool ?? false)&&($0["hittable"] as? Bool ?? false)};return output("可疑装饰覆盖", "\(risky.count) 层", "需以真机 hitTest 路径确认,不直接更改 SwiftUI 层级。") + case 35: + let tries=max(0,Int(number(x,"edgeAttempts"))),failed=max(0,Int(number(x,"edgeFailed")));return output("边缘交互失败比",pct(ratio(min(tries,failed),tries)),"仅适用于已人工标记成功/失败的手势样本。") + case 36: + let a=try nums(x,"beforeMs"),b=try nums(x,"afterMs");let shift=median(b)-median(a);return output("主题变更后触摸延迟", "\(Int(shift)) ms", "正值为变慢;样本不能证明主题切换是因果因素。") + case 37: + let a=try words(x,"focusEvents");let loops=zip(a,a.dropFirst()).filter{$0=="end" && $1=="begin"}.count;return output("焦点重入循环", "\(loops) 次", "可提示系统键盘/输入框复建;不记录真实文本。") + case 38: + let a=try nums(x,"frameMs");let stalls=a.filter{$0>33.3};return output("连续明显卡顿", "最长 \(longest(a.map{$0>33.3})) 帧", "超过 33.3 ms 共 \(stalls.count)/\(a.count);近似 UI 帧耗时。") + case 39: + let a=try nums(x,"frameMs"),b=try nums(x,"thermalLevels");guard a.count==b.count else{throw VeilRareLabError.badInput("热状态与帧耗时样本需等长")};let normal=zip(a,b).filter{$1<2}.map{$0.0},hot=zip(a,b).filter{$1>=2}.map{$0.0};return output("热负荷卡顿差", "\(Int(median(hot)-median(normal))) ms", "热样本与非热样本中位数差;共同变化不表示热状态造成卡顿。") + case 40: + guard let events=x["events"] as? [[Any]],events.count<=2000,events.allSatisfy({$0.count==2 && $0[0] is NSNumber && $0[1] is String}) else{throw VeilRareLabError.badInput("events 需要 [[时间戳,事件名],...]")};let window=max(0,min(10000,number(x,"faultWindowMs",fallback:300)));let a=events.compactMap { row -> (Double,String)? in guard let t=row[0] as? NSNumber,let name=row[1] as? String,t.doubleValue.isFinite,name.utf8.count<=128 else{return nil};return (t.doubleValue,name)}.sorted{$0.0<$1.0};var pairs=0;for i in a.indices {var j=i+1;while j Bool { true } + private var startedAt: TimeInterval = 0 private var startPoint = CGPoint.zero private var lastPoint = CGPoint.zero @@ -844,6 +863,13 @@ private final class PassiveTouchRecorder: UIGestureRecognizer { override func touchesCancelled(_ touches: Set, with event: UIEvent) { state = .failed + // Only count cancellations in owner-enabled diagnostics, never content/location. + guard DeepTelemetry.shared.rawTouchCaptureEnabled else { return } + DiagnosticLogStore.shared.log( + .debug, .ui, event: "ui.raw.cancelled", + screen: DeepTelemetry.shared.currentScreen, + metadata: ["touch_count": String(touches.count)] + ) } override func reset() { diff --git a/VeilLink/Diagnostics/DeviceStressTestController.swift b/VeilLink/Diagnostics/DeviceStressTestController.swift index 15f41479..df210668 100644 --- a/VeilLink/Diagnostics/DeviceStressTestController.swift +++ b/VeilLink/Diagnostics/DeviceStressTestController.swift @@ -512,18 +512,30 @@ final class DeviceStressTestController: ObservableObject { } await step("a9.refresh", cycle: cycle, configuration: configuration) { + let mode = model.kernelRuntime.mode + let expectsA9Evaluation = mode == .a9Only || mode == .dualShadow let previousA9Evaluation = model.a9Health.lastEvaluatedAt let previousRuntimeSample = model.a10Ultra.lastRuntimeSample?.sampleID model.refreshA9Health() - guard let evaluatedAt = model.a9Health.lastEvaluatedAt, - evaluatedAt != previousA9Evaluation else { - throw StressFailure("A9 health evaluation timestamp did not advance") + let a9Result: String + if expectsA9Evaluation { + guard let evaluatedAt = model.a9Health.lastEvaluatedAt, + evaluatedAt != previousA9Evaluation else { + throw StressFailure("A9 health evaluation timestamp did not advance") + } + a9Result = "A9 evaluated at \(ISO8601DateFormatter().string(from: evaluatedAt))" + } else { + // A10 independent/LAB modes deliberately leave A9 evaluation unchanged. + guard model.a9Health.lastEvaluatedAt == previousA9Evaluation else { + throw StressFailure("A9 evaluated unexpectedly in mode \(mode.rawValue)") + } + a9Result = "A9 correctly inactive in \(mode.rawValue)" } guard let runtimeSample = model.a10Ultra.lastRuntimeSample, runtimeSample.sampleID != previousRuntimeSample else { - throw StressFailure("A10 runtime sample did not advance in mode \(model.kernelRuntime.mode.rawValue)") + throw StressFailure("A10 runtime sample did not advance in mode \(mode.rawValue)") } - return .passed("A9 evaluated at \(ISO8601DateFormatter().string(from: evaluatedAt)); A10 runtime sample \(runtimeSample.sampleID) recorded (\(runtimeSample.divergenceClass))") + return .passed("\(a9Result); A10 runtime sample \(runtimeSample.sampleID) recorded (\(runtimeSample.divergenceClass))") } await step("ui.tabs.agent", cycle: cycle, configuration: configuration) { diff --git a/VeilLink/Diagnostics/RuntimeDiagnosticsBridge.swift b/VeilLink/Diagnostics/RuntimeDiagnosticsBridge.swift index b9352412..010124a7 100644 --- a/VeilLink/Diagnostics/RuntimeDiagnosticsBridge.swift +++ b/VeilLink/Diagnostics/RuntimeDiagnosticsBridge.swift @@ -601,6 +601,9 @@ final class RuntimeDiagnosticsBridge { "Render: \(VeilRenderProfile.diagnosticLabel)", "Device profile: \(VeilDevicePerformance.diagnosticLabel)", "", + // Diagnostic evidence only: no inferred elevated OS permission. + VeilRuntimeEnvironment.current.diagnosticsReport, + "", model.bluetooth.diagnosticsReport(), "", model.a9DiagnosticsReport(), diff --git a/VeilLink/Diagnostics/VeilPreConsentBlackBox.swift b/VeilLink/Diagnostics/VeilPreConsentBlackBox.swift index d7239578..8b2f4d1e 100644 --- a/VeilLink/Diagnostics/VeilPreConsentBlackBox.swift +++ b/VeilLink/Diagnostics/VeilPreConsentBlackBox.swift @@ -34,6 +34,11 @@ final class VeilPreConsentBlackBox { case signIncomplete case signSaved case signStorageFailed + case startupFailed + case startupKeyMissing + case startupKeyUnavailable + case startupKeyMismatch + case startupOtherFailure case exportRequested case exportPrepared case exportFailed @@ -141,6 +146,7 @@ final class VeilPreConsentBlackBox { /// Mounted before the legal gate is satisfied; never calls the normal AppModel-driven exporter. struct VeilPreConsentBlackBoxButton: View { + var stage: VeilPreConsentBlackBox.Stage = .consent @State private var preparedFile: URL? @State private var presentsShare = false @State private var exportError: String? @@ -153,13 +159,13 @@ struct VeilPreConsentBlackBoxButton: View { Spacer(minLength: 4) Button { let box = VeilPreConsentBlackBox.shared - box.record(.exportRequested, stage: .consent) + box.record(.exportRequested, stage: stage) do { preparedFile = try box.exportFile() - box.record(.exportPrepared, stage: .consent) + box.record(.exportPrepared, stage: stage) presentsShare = true } catch { - box.record(.exportFailed, stage: .consent) + box.record(.exportFailed, stage: stage) exportError = "无法创建本地诊断文件。请检查设备可用空间。" } } label: { diff --git a/VeilLink/Entertainment/Rendering/VeilFramePacer.swift b/VeilLink/Entertainment/Rendering/VeilFramePacer.swift new file mode 100644 index 00000000..6c0ea869 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilFramePacer.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Fixed-step simulation clock separate from the renderer's variable refresh. +/// Prevents background catch-up storms and long-frame physics explosions. +public struct VeilFramePacer: Sendable { + public struct Step: Equatable, Sendable { + public let simulationSteps: Int + public let stepSeconds: Double + public let interpolation: Double + public let droppedSeconds: Double + } + public static let fixedStep: Double = 1.0 / 60 + private var previous: Double? + private var remainder = 0.0 + public private(set) var totalDroppedSeconds = 0.0 + public private(set) var totalSimSteps = 0 + public init() {} + public mutating func reset() { previous = nil; remainder = 0 } + public mutating func advance(now: Double, maxCatchUpSteps: Int = 4) -> Step? { + guard now.isFinite, now >= 0, (1...8).contains(maxCatchUpSteps) else { return nil } + guard let last = previous else { previous = now; return Step(simulationSteps: 0, stepSeconds: Self.fixedStep, interpolation: 0, droppedSeconds: 0) } + guard now >= last else { return nil } + previous = now + // Never force 60 simulated seconds after a 60s background pause. + let delta = min(now - last, 0.25) + let dropped = max(0, now - last - delta) + remainder += delta + let requested = Int((remainder + 1e-10) / Self.fixedStep) + let steps = min(requested, maxCatchUpSteps) + remainder -= Double(steps) * Self.fixedStep + let cappedRemainder = min(remainder, Self.fixedStep * 0.999999) + let overflow = max(0, remainder - cappedRemainder) + remainder = cappedRemainder + totalDroppedSeconds += dropped + overflow + totalSimSteps += steps + return Step(simulationSteps: steps, stepSeconds: Self.fixedStep, + interpolation: max(0, min(remainder / Self.fixedStep, 1)), droppedSeconds: dropped + overflow) + } +} + +/// Rendering budgets are targets, not proof of physical-device performance. +/// Limits only the local entertainment scene, not the chat application's global refresh. +public struct VeilRenderBudget: Equatable, Sendable { + public enum Tier: String, Codable, Sendable { case legacy, balanced, high } + public let tier: Tier + public let targetFPS: Int + public let visibleNotes: Int + public let particleBudget: Int + public let maxSprites: Int + public init(tier: Tier) { + self.tier = tier + switch tier { + case .legacy: targetFPS = 30; visibleNotes = 5; particleBudget = 0; maxSprites = 48 + case .balanced: targetFPS = 30; visibleNotes = 8; particleBudget = 16; maxSprites = 96 + case .high: targetFPS = 60; visibleNotes = 12; particleBudget = 32; maxSprites = 160 + } + } +} diff --git a/VeilLink/Entertainment/Rendering/VeilRenderFrameStats.swift b/VeilLink/Entertainment/Rendering/VeilRenderFrameStats.swift new file mode 100644 index 00000000..7b5746a8 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRenderFrameStats.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Bounded, local-only update-callback cadence counters, not GPU timing. +/// Never records touch location, personal data, media or message content. +public struct VeilRenderFrameStats: Sendable { + /// Statistical summary of callback intervals only; these are not GPU timings. + public struct Report: Equatable, Sendable { + /// Count of accepted positive intervals (at most 240). + public let sampleCount: Int + /// Median callback interval in milliseconds (not achieved display FPS). + public let medianIntervalMs: Double + /// 95th percentile callback interval in milliseconds. + public let p95IntervalMs: Double + /// Intervals above 1/24 second within the current rolling sample. + public let slowFrames: Int + /// Sum of accepted sample intervals, omitting background gaps. + public let elapsedSeconds: Double + } + /// Fixed maximum retention to avoid unbounded diagnostics growth. + public static let maximumSamples = 240 + private var intervals: [Double] = [] + private var nextSlot = 0 + private var lastTime: Double? + /// Creates an empty in-memory local-only metrics recorder. + public init() { intervals.reserveCapacity(Self.maximumSamples) } + /// Discards all samples and previous clock anchor after scene lifecycle changes. + public mutating func reset() { + intervals.removeAll(keepingCapacity: true) + nextSlot = 0 + lastTime = nil + } + /// Record only finite monotonic callback times; avoid counting background gaps. + public mutating func observe(_ now: Double) { + guard now.isFinite, now >= 0 else { return } + guard let last = lastTime else { lastTime = now; return } + guard now >= last else { lastTime = now; return } + lastTime = now + let delta = now - last + guard delta > 0, delta <= 0.3 else { return } + if intervals.count < Self.maximumSamples { intervals.append(delta) } + else { + intervals[nextSlot] = delta + nextSlot = (nextSlot + 1) % Self.maximumSamples + } + } + /// A read-only snapshot; sorting is bounded by 240 samples. + public var report: Report { + let sorted = intervals.sorted() + guard let middle = sorted.first else { + return .init(sampleCount: 0, medianIntervalMs: 0, + p95IntervalMs: 0, slowFrames: 0, elapsedSeconds: 0) + } + let median = sorted[sorted.count/2] + let p95 = sorted[min(sorted.count-1, Int(Double(sorted.count-1)*0.95 + 0.999999))] + return .init(sampleCount: sorted.count, + medianIntervalMs: median * 1000, + p95IntervalMs: max(middle,p95) * 1000, + slowFrames: intervals.filter { $0 > 1.0/24.0 }.count, + elapsedSeconds: intervals.reduce(0,+)) + } +} diff --git a/VeilLink/Entertainment/Rendering/VeilRenderGeometry.swift b/VeilLink/Entertainment/Rendering/VeilRenderGeometry.swift new file mode 100644 index 00000000..1f217db5 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRenderGeometry.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Renderer-neutral, UIKit TOP-LEFT coordinate system measured in logical points. +/// Never confuse points, physical pixels or SpriteKit Y-up world coordinates. +public enum VeilRenderGeometry { + public struct Point: Equatable, Sendable { public var x: Double; public var y: Double + public init(_ x: Double, _ y: Double) { self.x = x; self.y = y } + } + public struct Size: Equatable, Sendable { public var width: Double; public var height: Double + public init(_ width: Double, _ height: Double) { self.width = width; self.height = height } + public var valid: Bool { width.isFinite && height.isFinite && width > 0 && height > 0 } + } + public struct Insets: Equatable, Sendable { public var top, left, bottom, right: Double + public init(top: Double = 0, left: Double = 0, bottom: Double = 0, right: Double = 0) { + self.top = top; self.left = left; self.bottom = bottom; self.right = right + } + public var valid: Bool { [top, left, bottom, right].allSatisfy { $0.isFinite && $0 >= 0 } } + } + public struct Rect: Equatable, Sendable { + public let x, y, width, height: Double + public func contains(_ p: Point) -> Bool { + x <= p.x && p.x <= x + width && y <= p.y && p.y <= y + height + } + } + public struct Projection: Equatable, Sendable { + public let canvas: Size + public let available: Rect + public let viewport: Rect + public let scale: Double + public let pixelsPerPoint: Double + public func toView(_ logical: Point) -> Point { + Point(viewport.x + logical.x * scale, viewport.y + logical.y * scale) + } + public func toLogical(_ view: Point) -> Point? { + guard viewport.contains(view) else { return nil } // Letterbox is never tappable. + return Point((view.x - viewport.x) / scale, (view.y - viewport.y) / scale) + } + public func toSpriteKit(_ logical: Point) -> Point { + Point(logical.x, canvas.height - logical.y) + } + public var aspectDistortion: Double { abs(viewport.width / canvas.width - viewport.height / canvas.height) } + public var drawablePixels: Size { Size(viewport.width * pixelsPerPoint, viewport.height * pixelsPerPoint) } + } + public enum Failure: Error, Equatable { case invalidCanvas, invalidView, invalidInsets, noDrawableArea, invalidScale } + /// aspectFIT: preserve whole interactive canvas on every device, with symmetric letterboxing. + /// Safe-area applies only when the container has not already been inset by SwiftUI/UIKit. + public static func fit(canvas: Size, in view: Size, insets: Insets = .init(), pixelsPerPoint: Double = 2) throws -> Projection { + guard canvas.valid else { throw Failure.invalidCanvas } + guard view.valid else { throw Failure.invalidView } + guard insets.valid else { throw Failure.invalidInsets } + guard pixelsPerPoint.isFinite && pixelsPerPoint > 0 && pixelsPerPoint <= 8 else { throw Failure.invalidScale } + let width = view.width - insets.left - insets.right + let height = view.height - insets.top - insets.bottom + guard width > 0 && height > 0 else { throw Failure.noDrawableArea } + let scale = min(width / canvas.width, height / canvas.height) + guard scale.isFinite && scale > 0 else { throw Failure.noDrawableArea } + let viewportW = canvas.width * scale + let viewportH = canvas.height * scale + return Projection(canvas: canvas, + available: Rect(x: insets.left, y: insets.top, width: width, height: height), + viewport: Rect(x: insets.left + (width - viewportW) * 0.5, + y: insets.top + (height - viewportH) * 0.5, + width: viewportW, height: viewportH), + scale: scale, pixelsPerPoint: pixelsPerPoint) + } +} diff --git a/VeilLink/Entertainment/Rendering/VeilRhythmNativeScene.swift b/VeilLink/Entertainment/Rendering/VeilRhythmNativeScene.swift new file mode 100644 index 00000000..86e92f93 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRhythmNativeScene.swift @@ -0,0 +1,135 @@ +#if canImport(UIKit) && canImport(SpriteKit) +import UIKit +import SpriteKit +import SwiftUI + +/// R6 rendering-only scene, intentionally touch transparent. +/// SpriteKit paints with a FIXED 390x600 coordinate system and aspectFit scaling. +/// Logic and judgement are owned by VeilRhythmLiveConductor, not render frames. +@MainActor +final class VeilRhythmNativeScene: SKScene { + struct Snapshot { + let seed: UInt64 + let district: Int + let nextBeat: Int + let secondsToNextBeat: Double? + let sampledUptime: Double + let running: Bool + } + private let maxNotePool = VeilRenderBudget(tier: .legacy).visibleNotes + private var notes: [SKShapeNode] = [] + private var latest: Snapshot? + private var cachedChart: VeilRhythmRenderCache? + private var stats = VeilRenderFrameStats() + private let noteColor = UIColor(red: 0.27, green: 0.89, blue: 1, alpha: 1) + private let rail = UIColor(red: 0.21, green: 0.30, blue: 0.39, alpha: 1) + + override init(size: CGSize) { + super.init(size: size) + self.scaleMode = .aspectFit // DO NOT replace with .fill or .resizeFill. + self.anchorPoint = .zero + self.backgroundColor = UIColor(red: 0.025, green: 0.036, blue: 0.08, alpha: 1) + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("Use init(size:)") } + override func didMove(to view: SKView) { + if !notes.isEmpty { return } + // All geometry measured in virtual points; no physical pixel sizes. + for lane in 0..<4 { + let cx = CGFloat(Double(lane) + 0.5) * (size.width / 4) + let railNode = SKShapeNode(rectOf: CGSize(width: size.width / 4 - 12, height: 460), cornerRadius: 13) + railNode.fillColor = rail.withAlphaComponent(0.18) + railNode.strokeColor = rail.withAlphaComponent(0.52) + railNode.lineWidth = 1 + railNode.position = CGPoint(x: cx, y: size.height - 300) + railNode.zPosition = 0 + addChild(railNode) + } + let strike = SKShapeNode(rectOf: CGSize(width: size.width - 30, height: 3)) + strike.position = CGPoint(x: size.width * 0.5, y: size.height - CGFloat(VeilRhythmVisualTimeline.strikeY)) + strike.fillColor = UIColor(red: 1, green: 0.7, blue: 0.4, alpha: 0.94) + strike.strokeColor = .clear + strike.zPosition = 3 + addChild(strike) + for _ in 0.. Coordinator { Coordinator() } + @MainActor final class Coordinator { + let scene = VeilRhythmNativeScene(size: CGSize(width: 390, height: 600)) + } + func makeUIView(context: Context) -> SKView { + let sk = SKView(frame: .zero) + sk.backgroundColor = .black + sk.isUserInteractionEnabled = false + sk.isMultipleTouchEnabled = false + sk.preferredFramesPerSecond = 30 // requested, not guaranteed actual FPS + sk.isAsynchronous = true + sk.ignoresSiblingOrder = true // every node has explicit zPosition + sk.shouldCullNonVisibleNodes = true + sk.showsFPS = false + sk.showsNodeCount = false + sk.presentScene(context.coordinator.scene) + return sk + } + func updateUIView(_ uiView: SKView, context: Context) { + context.coordinator.scene.apply(snapshot) + } + static func dismantleUIView(_ uiView: SKView, coordinator: Coordinator) { + uiView.isPaused = true + uiView.presentScene(nil) + } +} +#endif diff --git a/VeilLink/Entertainment/Rendering/VeilRhythmR6NativeGameView.swift b/VeilLink/Entertainment/Rendering/VeilRhythmR6NativeGameView.swift new file mode 100644 index 00000000..a99e60a0 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRhythmR6NativeGameView.swift @@ -0,0 +1,112 @@ +#if canImport(UIKit) && canImport(SpriteKit) +import SwiftUI +import Foundation + +/// R7 improved iOS15 native-scene game prototype. Does not attach UIWindow recognizers, +/// alter existing GameLobby, or mutate transport/database/key material. +struct VeilRhythmR6NativeGameView: View { + @Environment(\.scenePhase) private var scenePhase + @State private var game = VeilRhythmLiveConductor(seed: 20261025) + @State private var nowValue = 0.0 + @State private var status = "准备开始:请在音符到达金色判定线时按下对应轨道。" + @State private var initiated = false + private let titles = ["左一", "左二", "右二", "右一"] + private func now() -> Double { ProcessInfo.processInfo.systemUptime } + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 12) { + Text("雨夜回声 · 原生渲染 R7").font(.headline) + Text("SpriteKit 画面 · Swift 游戏逻辑 · 不接入网络") + .font(.caption).foregroundColor(.secondary) + TimelineView(.animation(minimumInterval: 1.0 / 12.0, paused: !game.isRunning)) { timeline in + VStack(spacing: 9) { + let frame = game.frame(at: nowValue) + Text("区域 \(frame.district + 1)/4 · 节拍 \(min(frame.beat+1,16))/16 · 分数 \(frame.score) · 能量 \(frame.focus)") + .font(.caption.monospacedDigit()) + VeilRhythmNativeStage(snapshot: .init( + seed: game.session.seed, + district: frame.district, nextBeat: frame.beat, + secondsToNextBeat: frame.secondsToBeat, + sampledUptime: nowValue, running: frame.running)) + .aspectRatio(390.0 / 600.0, contentMode: .fit) + .frame(height: min(400, max(200, geometry.size.height * 0.42))) + .frame(maxWidth: 440) + .frame(maxWidth: .infinity) + .accessibilityLabel("四轨节奏画面,底部有可操作的轨道按钮") + .accessibilityAddTraits(.isImage) + } + .onChange(of: timeline.date) { _ in tick() } + } + Text(status).font(.caption).frame(maxWidth: .infinity, alignment: .leading) + HStack { + if !initiated { + Button("开始演奏") { begin() }.buttonStyle(.borderedProminent) + } else if game.isPaused { + Button("恢复") { resume() }.buttonStyle(.borderedProminent) + } else if game.isRunning { + Button("暂停") { pause() }.buttonStyle(.bordered) + } + Button("重来") { game = .init(seed: 20261025); initiated = false; nowValue = now(); status = "重置完成" } + .buttonStyle(.bordered) + } + if game.session.districtCompleted && game.session.phase == .playing { + Button("进入下一幕") { travel() }.buttonStyle(.borderedProminent) + } + if game.session.phase != .playing { Text(game.session.phase == .cleared ? "通关" : "本局结束") } + } + .padding(12) + } + laneDock // Fixed bottom dock: scrolling artwork cannot steal taps. + } + } + .navigationTitle("节律远征") + .onChange(of: scenePhase) { phase in if phase != .active { pause() } } + .onDisappear { pause() } + } + private var laneDock: some View { + HStack(spacing: 6) { + ForEach(0..<4, id: \.self) { lane in + Button(titles[lane]) { hit(lane) } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity, minHeight: 56) + .disabled(!game.isRunning) + .accessibilityLabel("第 \(lane + 1) 轨,\(titles[lane])") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground)) + } + private func begin() { + let t = now(); do { try game.start(at:t); nowValue=t; initiated=true; status="已开始" } + catch { status="无法开始:\(error)" } + } + private func hit(_ lane:Int) { + let t=now() + do { + let quality=try game.tap(lane:lane,at:t) + nowValue=t + status="判定 \(quality)" + } catch VeilRhythmLiveConductor.Failure.tooEarly { status="尚未到达判定线" } + catch { status="操作未生效:\(error)" } + } + private func tick() { + guard game.isRunning else { return } + let t=now(); do { let skipped=try game.advance(to:t); nowValue=t; if skipped>0 { status="漏击 \(skipped) 音" } } + catch { status="计时异常:\(error)" } + } + private func pause() { + guard game.isRunning else { return } + do { try game.pause(at:now()); status="已暂停" } catch { status="暂停错误:\(error)" } + } + private func resume() { + let t=now(); do { try game.resume(at:t); nowValue=t; status="继续" } catch { status="恢复错误:\(error)" } + } + private func travel() { + let t=now(); do { try game.travel(at:t); try game.start(at:t); nowValue=t; status="新章节开始" } + catch { status="切换失败:\(error)" } + } +} +#endif diff --git a/VeilLink/Entertainment/Rendering/VeilRhythmRenderCache.swift b/VeilLink/Entertainment/Rendering/VeilRhythmRenderCache.swift new file mode 100644 index 00000000..351ca85d --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRhythmRenderCache.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Precomputes chart metadata once per seed/district. SpriteKit reads bounded +/// value snapshots and never owns game rules, timing decisions or identities. +public struct VeilRhythmRenderCache: Sendable { + /// Immutable seed used to generate the same sixteen notes on every replay. + public let seed: UInt64 + /// Zero-based district, accepted range 0...3; never a remote peer identifier. + public let district: Int + private let schedule: [VeilRhythmExpedition.Note] + /// Builds a fixed-size (16 note) cache; fails for unknown districts. + /// No GPU nodes, timers, filesystem reads or network calls are created. + public init?(seed: UInt64, district: Int) { + guard (0..<4).contains(district) else { return nil } + self.seed = seed + self.district = district + var items: [VeilRhythmExpedition.Note] = [] + for beat in 0..<16 { + guard let n = VeilRhythmExpedition.note(seed: seed, district: district, beat: beat) else { return nil } + items.append(n) + } + schedule = items + } + /// Computes at most `capacity` visible note placements without any allocation + /// outside the returned value array. `secondsToNextBeat` may be negative + /// inside the judgment window; NaN/infinite timestamps are rejected. + public func visible(nextBeat: Int, secondsToNextBeat: Double, + capacity: Int = 5) -> [VeilRhythmVisualTimeline.VisibleNote] { + guard (0..<16).contains(nextBeat), (1...16).contains(capacity), + secondsToNextBeat.isFinite else { return [] } + let nextTime = schedule[nextBeat].nominalTime + let end = min(16, nextBeat + capacity) + var visible: [VeilRhythmVisualTimeline.VisibleNote] = [] + visible.reserveCapacity(capacity) + for beat in nextBeat..= -VeilRhythmLiveConductor.hitWindow, + remaining <= VeilRhythmVisualTimeline.travelSeconds else { continue } + let progress = max(0, min(1, 1 - remaining / VeilRhythmVisualTimeline.travelSeconds)) + visible.append(.init(beat: beat, lane: n.lane, + x: (Double(n.lane) + 0.5) * VeilRhythmVisualTimeline.canvas.width / 4, + y: VeilRhythmVisualTimeline.spawnY + + (VeilRhythmVisualTimeline.strikeY - VeilRhythmVisualTimeline.spawnY) * progress, + secondsUntilHit: remaining)) + } + return visible + } +} + +/// A stopped producer must not leave misleading, eternally frozen notes visible. +/// Paused content is held by design; running content goes blank until fresh data. +public enum VeilRenderSnapshotWatchdog { + /// Clock classification: a running producer is stale after 280ms; + /// `fresh(age)` is clamped to a nonnegative extrapolation interval. + public enum Status: Equatable, Sendable { case fresh(Double), stale, invalid } + /// The latest running snapshot is invalidated after 280ms without updates. + public static let maximumRunningAge = 0.28 + /// Tolerate only 15ms of sampling skew; reject greater future timestamps. + public static let maximumClockSkew = 0.015 + /// Evaluates two timestamps from the *same monotonic clock domain*. + /// Paused state freezes intentionally; invalid numbers fail closed. + /// The renderer must hide nodes on `stale` or `invalid`. + public static func check(now: Double, sampledAt: Double, running: Bool) -> Status { + guard now.isFinite, sampledAt.isFinite, now >= 0, sampledAt >= 0 else { return .invalid } + let age = now - sampledAt + guard age >= -maximumClockSkew else { return .invalid } + if !running { return .fresh(0) } + guard age <= maximumRunningAge else { return .stale } + return .fresh(max(0, age)) + } +} diff --git a/VeilLink/Entertainment/Rendering/VeilRhythmVisualTimeline.swift b/VeilLink/Entertainment/Rendering/VeilRhythmVisualTimeline.swift new file mode 100644 index 00000000..e5704103 --- /dev/null +++ b/VeilLink/Entertainment/Rendering/VeilRhythmVisualTimeline.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Pure presentational projection, read-only from an existing session and timing frame. +/// The visual note lane never chooses the judged lane; gameplay remains in LiveConductor. +public enum VeilRhythmVisualTimeline { + public struct VisibleNote: Equatable, Sendable { + public let beat: Int + public let lane: Int + public let x, y: Double // canonical top-left 390x600 logical canvas + public let secondsUntilHit: Double + } + public static let canvas = VeilRenderGeometry.Size(390, 600) + public static let strikeY: Double = 500 + public static let spawnY: Double = 80 + public static let travelSeconds = 1.6 + + /// The playback time axis comes from the injected monotonic host clock, not animation frame indices. + public static func notes(seed: UInt64, district: Int, nextBeat: Int, + secondsToNextBeat: Double?, capacity: Int = 8) -> [VisibleNote] { + guard let remaining = secondsToNextBeat, remaining.isFinite, (0..<4).contains(district), + (0..<16).contains(nextBeat), (1...16).contains(capacity), + let next = VeilRhythmExpedition.note(seed: seed, district: district, beat: nextBeat) else { return [] } + var result: [VisibleNote] = [] + for beat in nextBeat..= -0.18 && seconds <= travelSeconds else { continue } + let unit = max(0, min(1, 1 - seconds / travelSeconds)) + let x = (Double(note.lane) + 0.5) * canvas.width / 4 + let y = spawnY + (strikeY - spawnY) * unit + result.append(VisibleNote(beat: beat, lane: note.lane, x: x, y: y, secondsUntilHit: seconds)) + } + return result + } +} diff --git a/VeilLink/Entertainment/VeilEntertainmentFeatureGate.swift b/VeilLink/Entertainment/VeilEntertainmentFeatureGate.swift new file mode 100644 index 00000000..65ac76c0 --- /dev/null +++ b/VeilLink/Entertainment/VeilEntertainmentFeatureGate.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Design-time admission rules; does NOT install or activate App features. +/// In particular a catalogue entry must never become a tappable App feature +/// simply because it has a detailed description or a candidate Swift file. +public enum VeilEntertainmentFeatureGate { + public static let catalogCount = 200 + public static let candidateNumbers: Set = [1, 4, 6, 11, 91, 101, 121, 191] + + public enum State: String, Codable, Sendable { + case designed + case candidateCore + } + + public struct Evidence: Codable, Equatable, Sendable { + public let productCompiled: Bool + public let incidentP0Cleared: Bool + public let ios15DevicePassed: Bool + public let userOptedIn: Bool + public init(productCompiled: Bool = false, + incidentP0Cleared: Bool = false, + ios15DevicePassed: Bool = false, + userOptedIn: Bool = false) { + self.productCompiled = productCompiled + self.incidentP0Cleared = incidentP0Cleared + self.ios15DevicePassed = ios15DevicePassed + self.userOptedIn = userOptedIn + } + public var satisfied: Bool { + productCompiled && incidentP0Cleared && ios15DevicePassed && userOptedIn + } + } + + public static func id(for number: Int) -> String? { + guard (1...catalogCount).contains(number) else { return nil } + return number < 91 ? String(format: "E%02d", number) : String(format: "E%03d", number) + } + + public static func number(for id: String) -> Int? { + guard id.first == "E", let value = Int(id.dropFirst()), + let canonical = self.id(for: value), canonical == id else { return nil } + return value + } + + public static func state(for id: String) -> State? { + guard let value = number(for: id) else { return nil } + return candidateNumbers.contains(value) ? .candidateCore : .designed + } + + /// Gate only: the caller is still responsible for enforcing those proofs. + /// The gate cannot generate or certify device evidence by itself. + public static func canPresent(_ id: String, evidence: Evidence = Evidence()) -> Bool { + state(for: id) == .candidateCore && evidence.satisfied + } +} diff --git a/VeilLink/Entertainment/VeilRhythmExpeditionEngine.swift b/VeilLink/Entertainment/VeilRhythmExpeditionEngine.swift new file mode 100644 index 00000000..af6d627d --- /dev/null +++ b/VeilLink/Entertainment/VeilRhythmExpeditionEngine.swift @@ -0,0 +1,128 @@ +import Foundation + +/// E191 prototype: deterministic four-district rhythm exploration. +/// Pure Foundation; no microphone, player identity, BLE or app database access. +public enum VeilRhythmExpedition { + public static let districtCount = 4 + public static let beatsPerDistrict = 16 + public static let maxHistory = 256 + + public enum District: Int, Codable, CaseIterable, Sendable { + case rainAlley, emptyStation, riverEcho, observatory + public var title: String { + switch self { + case .rainAlley: return "雨巷初响" + case .emptyStation: return "无人车站" + case .riverEcho: return "河岸回声" + case .observatory: return "穹顶演出" + } + } + } + + public enum Phase: String, Codable, Sendable { case playing, cleared, failed } + public enum Quality: String, Codable, Sendable { case perfect, good, miss } + public enum Command: Codable, Equatable, Sendable { + case tap(beat: Int, lane: Int, offset: Double) + case travel + } + public enum Failure: Error, Equatable { + case badBeat, badLane, badOffset, districtLocked, inactive, sessionTooLong + } + + public struct Note: Codable, Equatable, Sendable { + public let district: Int + public let beat: Int + public let lane: Int + public let nominalTime: Double + } + public struct Event: Codable, Equatable, Sendable { + public let command: Command + public let district: Int + public let quality: Quality? + public let score: Int + public let focus: Int + } + + // Same seed and district always produce the same four-lane chart. + public static func note(seed: UInt64, district: Int, beat: Int) -> Note? { + guard (0..> 30)) &* 0xbf58476d1ce4e5b9 + let lane = Int((a ^ (a >> 27)) % 4) + return Note(district: district, beat: beat, lane: lane, + nominalTime: Double(beat) * (60.0 / (100.0 + Double(district) * 15.0))) + } + + public struct Session: Codable, Sendable { + public let seed: UInt64 + public private(set) var district = 0 + public private(set) var nextBeat = 0 + public private(set) var phase: Phase = .playing + public private(set) var score = 0 + public private(set) var combo = 0 + public private(set) var bestCombo = 0 + public private(set) var focus = 100 + public private(set) var collectedFragments = 0 + public private(set) var history: [Event] = [] + public private(set) var totalActions = 0 + public init(seed: UInt64) { self.seed = seed } + public var districtCompleted: Bool { nextBeat == beatsPerDistrict } + public var expectedNote: Note? { VeilRhythmExpedition.note(seed: seed, district: district, beat: nextBeat) } + public var progress: Double { + Double(district * beatsPerDistrict + nextBeat) / Double(districtCount * beatsPerDistrict) + } + + @discardableResult + public mutating func perform(_ command: Command) throws -> Event { + guard phase == .playing else { throw Failure.inactive } + guard totalActions < 1024 else { throw Failure.sessionTooLong } + var judgement: Quality? + switch command { + case let .tap(beat, lane, offset): + guard !districtCompleted, beat == nextBeat else { throw Failure.badBeat } + guard (0...3).contains(lane) else { throw Failure.badLane } + guard offset.isFinite, abs(offset) <= 1 else { throw Failure.badOffset } + let expected = VeilRhythmExpedition.note(seed: seed, district: district, beat: nextBeat)! + if expected.lane == lane && abs(offset) <= 0.075 { + judgement = .perfect + combo += 1 + score += 300 + min(combo, 30) * 2 + focus = min(100, focus + 2) + } else if expected.lane == lane && abs(offset) <= 0.18 { + judgement = .good + combo += 1 + score += 100 + min(combo, 30) + focus = min(100, focus + 1) + } else { + judgement = .miss + combo = 0 + focus = max(0, focus - 12) + } + bestCombo = max(bestCombo, combo) + nextBeat += 1 + if nextBeat == beatsPerDistrict { collectedFragments += 1 } + if focus == 0 { phase = .failed } + if district == districtCount - 1 && districtCompleted && phase != .failed { phase = .cleared } + case .travel: + guard districtCompleted, district < districtCount - 1 else { throw Failure.districtLocked } + district += 1 + nextBeat = 0 + focus = min(100, focus + 10) + } + let event = Event(command: command, district: district, + quality: judgement, score: score, focus: focus) + totalActions += 1 + if history.count == VeilRhythmExpedition.maxHistory { history.removeFirst() } + history.append(event) + return event + } + } + + public static func replay(seed: UInt64, commands: [Command]) throws -> Session { + guard commands.count <= 1024 else { throw Failure.sessionTooLong } + var session = Session(seed: seed) + for command in commands { try session.perform(command) } + return session + } +} diff --git a/VeilLink/Entertainment/VeilRhythmLiveConductor.swift b/VeilLink/Entertainment/VeilRhythmLiveConductor.swift new file mode 100644 index 00000000..ee459fe0 --- /dev/null +++ b/VeilLink/Entertainment/VeilRhythmLiveConductor.swift @@ -0,0 +1,123 @@ +import Foundation + +/// E191 R5 rhythm timing core. Inject monotonic seconds from the host. +/// This type owns NO timers, audio session, BLE, SQLite or UI gesture recognizers. +/// Its schedule is gameplay timing, not an audio sample-accurate transport. +public struct VeilRhythmLiveConductor: Sendable { + public typealias Game = VeilRhythmExpedition + public static let leadIn: Double = 1.6 + public static let hitWindow: Double = 0.18 + + public enum Failure: Error, Equatable { + case invalidTime, notStarted, paused, invalidLane, tooEarly, inactive, stageNeedsTravel + } + public enum InputResult: Equatable { + case hit(Game.Quality) + case expired(Int) + } + public struct Frame: Equatable, Sendable { + public let district: Int + public let beat: Int + public let expectedLane: Int? + public let secondsToBeat: Double? + public let progress: Double + public let score: Int + public let focus: Int + public let running: Bool + public let finished: Bool + } + + public private(set) var session: Game.Session + private var anchor: Double? = nil + private var pausedAt: Double? = nil + private var lastTime: Double? = nil + + public init(seed: UInt64) { self.session = Game.Session(seed: seed) } + + /// Fresh clock anchor at the NEXT pending beat. Earlier beats in a decoded + /// snapshot are not silently re-judged; the caller must validate the snapshot. + public init(session: Game.Session) { self.session = session } + public var isRunning: Bool { anchor != nil && pausedAt == nil && session.phase == .playing } + public var isPaused: Bool { pausedAt != nil } + + private mutating func observe(_ now: Double) throws { + guard now.isFinite, now >= 0 else { throw Failure.invalidTime } + guard lastTime.map({ now >= $0 }) ?? true else { throw Failure.invalidTime } + lastTime = now + } + private func dueTime(for beat: Int) -> Double? { + guard let anchor = anchor, let note = Game.note(seed: session.seed, district: session.district, beat: beat) else { return nil } + return anchor + Self.leadIn + note.nominalTime + } + public mutating func start(at now: Double) throws { + try observe(now) + guard session.phase == .playing else { throw Failure.inactive } + guard !session.districtCompleted else { throw Failure.stageNeedsTravel } + guard anchor == nil else { return } + let offset = session.expectedNote?.nominalTime ?? 0 + anchor = now - offset + } + public mutating func pause(at now: Double) throws { + try observe(now) + guard anchor != nil else { throw Failure.notStarted } + if pausedAt == nil { pausedAt = now } + } + public mutating func resume(at now: Double) throws { + try observe(now) + guard let stoppedAt = pausedAt, let previousAnchor = anchor else { throw Failure.paused } + anchor = previousAnchor + (now - stoppedAt) + pausedAt = nil + } + /// Use this after app foreground return, never silently run missed beats in background. + public mutating func travel(at now: Double) throws { + try observe(now) + guard session.phase == .playing else { throw Failure.inactive } + guard session.districtCompleted else { throw Failure.stageNeedsTravel } + do { try session.perform(.travel) } + catch { throw Failure.stageNeedsTravel } + anchor = nil + pausedAt = nil + } + + /// Deterministically consumes expired notes; no timers required. + @discardableResult + public mutating func advance(to now: Double) throws -> Int { + try observe(now) + guard isRunning else { return 0 } + var expired = 0 + while session.phase == .playing && !session.districtCompleted, + let due = dueTime(for: session.nextBeat), now - due > Self.hitWindow { + let beat = session.nextBeat + // Well-formed intentionally missed input, never credited as accurate. + try session.perform(.tap(beat: beat, lane: session.expectedNote!.lane, offset: 1)) + expired += 1 + } + return expired + } + /// Host must pass measured monotonic tap timestamp, NEVER a user-provided offset. + public mutating func tap(lane: Int, at now: Double) throws -> InputResult { + try observe(now) + guard (0..<4).contains(lane) else { throw Failure.invalidLane } + guard isRunning else { throw isPaused ? Failure.paused : Failure.notStarted } + let expired = try advance(to: now) + guard session.phase == .playing else { throw Failure.inactive } + guard !session.districtCompleted else { return .expired(expired) } + guard let due = dueTime(for: session.nextBeat) else { throw Failure.inactive } + let offset = now - due + guard offset >= -Self.hitWindow else { throw Failure.tooEarly } + let next = session.nextBeat + let event = try session.perform(.tap(beat: next, lane: lane, offset: offset)) + return .hit(event.quality ?? .miss) + } + public func frame(at now: Double) -> Frame { + let seconds: Double? + if let due = dueTime(for: session.nextBeat), now.isFinite { + seconds = due - (pausedAt ?? now) + } else { seconds = nil } + return Frame(district: session.district, beat: session.nextBeat, + expectedLane: session.expectedNote?.lane, + secondsToBeat: seconds, progress: session.progress, + score: session.score, focus: session.focus, + running: isRunning, finished: session.phase != .playing) + } +} diff --git a/VeilLink/Entertainment/VeilStationChronicleEngine.swift b/VeilLink/Entertainment/VeilStationChronicleEngine.swift new file mode 100644 index 00000000..96ce1ea2 --- /dev/null +++ b/VeilLink/Entertainment/VeilStationChronicleEngine.swift @@ -0,0 +1,194 @@ +import Foundation + +/// E192 · Railway Station Chronicle: twenty scripted days, four fictional eras. +/// Purely local narrative/management rules; does not read real transit data. +public enum VeilStationChronicle { + public static let daysPerEra = 5 + public static let eraCount = 4 + public static let totalDays = daysPerEra * eraCount + + public enum Era: Int, Codable, CaseIterable, Sendable { + case steam, electrification, renovation, future + public var name: String { + switch self { + case .steam: return "蒸汽启程" + case .electrification: return "电光换乘" + case .renovation: return "旧站新生" + case .future: return "星轨终章" + } + } + public var narration: String { + switch self { + case .steam: return "雾散后的第一声汽笛,叫醒一座尚未命名的城市。" + case .electrification: return "电车取代了汽笛,站台的约定却没有变。" + case .renovation: return "旧站即将拆除,旅客递来一本泛黄的留言簿。" + case .future: return "新列车抵达时,时间和故事终于在同一条线上相遇。" + } + } + } + public enum Project: String, Codable, CaseIterable, Sendable { + case restore, archive, outreach, platform + public var price: Int { + switch self { + case .restore: return 40 + case .archive, .outreach: return 25 + case .platform: return 80 + } + } + } + public enum Command: Codable, Equatable, Sendable { + case undertake(Project) + case dispatch + case closeDay + } + public enum Phase: String, Codable, Sendable { case running, completed, bankrupt, abandoned } + public enum Failure: Error, Equatable { + case inactive, alreadyPlanned, insufficientFunds, alreadyDispatched, + dispatchRequired, duplicateClose, sessionTooLong, malformedSave + } + public struct Event: Codable, Equatable, Sendable { + public let day: Int + public let command: Command + public let trust: Int + public let funds: Int + public let condition: Int + public let served: Int + } + public struct Bulletin: Equatable, Sendable { + public let day: Int + public let era: Era + public let visitors: Int + public let speaker: String + public let message: String + } + public struct Game: Codable, Sendable { + public let seed: UInt64 + public private(set) var day = 0 + public private(set) var trust = 60 + public private(set) var funds = 240 + public private(set) var condition = 78 + public private(set) var capacity = 60 + public private(set) var historyPoints = 0 + public private(set) var totalServed = 0 + public private(set) var projectThisDay: Project? = nil + public private(set) var dispatchedThisDay = false + public private(set) var phase: Phase = .running + public private(set) var events: [Event] = [] + public private(set) var commands: [Command] = [] + public init(seed: UInt64) { self.seed = seed } + public var currentEra: Era { Era(rawValue: min(day / daysPerEra, 3))! } + public var currentDay: Int { min(day + 1, totalDays) } + public var progress: Double { Double(day) / Double(totalDays) } + public var epilogue: String { + guard phase == .completed else { return "列车仍在运行。" } + return historyPoints >= 4 && trust >= 65 ? + "旧站的故事被保存下来,下一代旅人仍能听见它的钟声。" : + "城市继续向前,未收集的记忆仍留在旧站的窗边。" + } + public var bulletin: Bulletin { + let names = ["站长澄川", "修复师铃野", "记录员遥", "旅客南星"] + let messages = VeilStationChronicle.dayStories + return Bulletin(day: currentDay, era: currentEra, visitors: VeilStationChronicle.demand(seed:seed, day:day), + speaker: names[currentEra.rawValue], message: messages[min(day, VeilStationChronicle.totalDays - 1)]) + } + @discardableResult + public mutating func apply(_ command: Command) throws -> Event { + guard phase == .running else { throw Failure.inactive } + guard commands.count < 100 else { throw Failure.sessionTooLong } + var next = self // transactional: failed operations never modify state. + switch command { + case .undertake(let project): + guard next.projectThisDay == nil else { throw Failure.alreadyPlanned } + guard next.funds >= project.price else { throw Failure.insufficientFunds } + next.funds -= project.price + next.projectThisDay = project + switch project { + case .restore: next.condition = min(100, next.condition + 18) + case .archive: next.historyPoints = min(20, next.historyPoints + 1); next.trust = min(100, next.trust + 3) + case .outreach: next.trust = min(100, next.trust + 9) + case .platform: next.capacity = min(140, next.capacity + 20) + } + case .dispatch: + guard !next.dispatchedThisDay else { throw Failure.alreadyDispatched } + next.dispatchedThisDay = true + let served = min(next.capacity, next.bulletin.visitors) + next.totalServed += served + next.funds = min(100_000, next.funds + served * 3) + let demand = next.bulletin.visitors + next.trust = max(0, min(100, next.trust + (served * 10 >= demand * 7 ? 2 : -5))) + next.condition = max(0, next.condition - (3 + next.currentEra.rawValue)) + if next.condition < 10 || next.trust == 0 { next.phase = .abandoned } + case .closeDay: + guard next.dispatchedThisDay else { throw Failure.dispatchRequired } + next.day += 1 + next.projectThisDay = nil + next.dispatchedThisDay = false + if next.day == VeilStationChronicle.totalDays { next.phase = .completed } + } + let served = next.totalServed - totalServed + let event = Event(day: day, command: command, trust: next.trust, + funds: next.funds, condition: next.condition, served: served) + next.commands.append(command) + if next.events.count == 256 { next.events.removeFirst() } + next.events.append(event) + self = next + return event + } + public func exportReplay() throws -> Data { + let record = Replay(version: 1, seed: seed, commands: commands) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(record) + } + } + public struct Replay: Codable, Sendable { + public let version: Int + public let seed: UInt64 + public let commands: [Command] + } + public static let dayStories: [String] = [ + "第一班试运行,煤烟与晨雾同时升起。旅客在手写时刻表前排成了队。", + "一位少女把遗失的怀表交给站长,问列车能不能带走想念。", + "暴雨浸湿木制站台,修复队决定先保住车票档案室。", + "小提琴家在候车厅演奏,孩子们终于听见远方城市的声音。", + "老站长留下第一本行车日志,封面写着:请把每一次抵达都记下来。", + "电车架线架起时,旧汽笛被郑重挂进车站纪念角。", + "钟表匠重新校准站台时钟,时间第一次变得安静而准确。", + "城市举办灯火夜行节,停电中的旅客帮助点亮一盏又一盏灯。", + "年轻维修师发现老日志夹着一张未寄出的家书。", + "第一列夜间电车准点进站,昔日怀表的主人归来了。", + "旧站修复公告贴出,人们对拆迁与保存提出不同意见。", + "记录员发起口述历史采访,售票员讲起四十年前的暴雪。", + "绘图师为旧建筑制作测绘图,墙面露出藏在灰尘后的壁画。", + "一班因道路修缮改道的列车载来寻找祖父故事的年轻人。", + "投票结束,人们决定让旧站钟楼与新线路共同存在。", + "未来轨道铺设完毕,新旧时刻表第一次并排显示。", + "数字档案室上线,老站长的笔记成为第一个可公开阅读的故事。", + "无人驾驶测试列车缓缓驶入,但站台仍保留人工欢迎广播。", + "车站举办跨时代照片展,曾经离别的人们在同一张合影里重逢。", + "最后一声钟响传过河岸,乘客们把下一百年的第一张车票交给孩子。" + ] + public static func demand(seed: UInt64, day: Int) -> Int { + guard (0..> 30)) &* 0xbf58476d1ce4e5b9 + v = (v ^ (v >> 27)) &* 0x94d049bb133111eb + v ^= v >> 31 + return 30 + Int(v % 61) + } + public static func replay(seed: UInt64, commands: [Command]) throws -> Game { + guard commands.count <= 100 else { throw Failure.sessionTooLong } + var game = Game(seed: seed) + for command in commands { try game.apply(command) } + return game + } + public static func importReplay(_ data: Data) throws -> Game { + guard data.count <= 32_768 else { throw Failure.malformedSave } + let record: Replay + do { record = try JSONDecoder().decode(Replay.self,from:data) } + catch { throw Failure.malformedSave } + guard record.version == 1, record.commands.count <= 100 else { throw Failure.malformedSave } + do { return try replay(seed: record.seed, commands: record.commands) } + catch { throw Failure.malformedSave } + } +} diff --git a/VeilLink/Entertainment/VeilStoryEngine.swift b/VeilLink/Entertainment/VeilStoryEngine.swift new file mode 100644 index 00000000..7746d970 --- /dev/null +++ b/VeilLink/Entertainment/VeilStoryEngine.swift @@ -0,0 +1,255 @@ +import Foundation + +/// Isolated, offline story engine. Never reads chat messages, identities, keys or database records. +struct VeilStoryCondition: Codable, Equatable, Sendable { + enum Kind: String, Codable, Sendable { case hasFlag, lacksFlag, hasItem, lacksItem, minimumScore } + let kind: Kind + let value: String + let minimum: Int? + + func passes(_ run: VeilStoryRun) -> Bool { + switch kind { + case .hasFlag: return run.flags.contains(value) + case .lacksFlag: return !run.flags.contains(value) + case .hasItem: return run.items.contains(value) + case .lacksItem: return !run.items.contains(value) + case .minimumScore: return run.score >= (minimum ?? 0) + } + } +} + +struct VeilStoryEffect: Codable, Equatable, Sendable { + enum Kind: String, Codable, Sendable { case gainItem, loseItem, setFlag, clearFlag, score } + let kind: Kind + let value: String + let amount: Int? +} + +struct VeilStoryChoice: Codable, Equatable, Identifiable, Sendable { + let id: String + let label: String + let destination: String + let requirements: [VeilStoryCondition] + let effects: [VeilStoryEffect] +} + +struct VeilStoryScene: Codable, Equatable, Identifiable, Sendable { + let id: String + let title: String + let narrative: String + let choices: [VeilStoryChoice] + let ending: String? +} + +struct VeilStoryBook: Codable, Equatable, Identifiable, Sendable { + let id: String + let title: String + let description: String + let startingScene: String + let scenes: [VeilStoryScene] + + func validate() throws { + guard !id.isEmpty, !title.isEmpty, !description.isEmpty, + !scenes.isEmpty, scenes.count <= 128 else { throw VeilStoryError.invalidBook } + let ids = scenes.map(\.id) + guard Set(ids).count == scenes.count, ids.contains(startingScene) else { + throw VeilStoryError.invalidBook + } + let sceneSet = Set(ids) + var reachable = Set() + var frontier = [startingScene] + while let sceneID = frontier.popLast() { + guard reachable.insert(sceneID).inserted else { continue } + guard let scene = scenes.first(where: { $0.id == sceneID }) else { + throw VeilStoryError.invalidBook + } + guard !scene.title.isEmpty, !scene.narrative.isEmpty, + scene.narrative.count <= 4_096, + scene.choices.count <= 8, + Set(scene.choices.map(\.id)).count == scene.choices.count, + (scene.ending == nil ? !scene.choices.isEmpty : scene.choices.isEmpty) else { + throw VeilStoryError.invalidBook + } + for choice in scene.choices { + guard !choice.id.isEmpty, !choice.label.isEmpty, + choice.label.count <= 200, sceneSet.contains(choice.destination), + choice.requirements.count <= 8, choice.effects.count <= 8 else { + throw VeilStoryError.invalidBook + } + for requirement in choice.requirements { + guard !requirement.value.isEmpty, requirement.value.count <= 80 else { + throw VeilStoryError.invalidBook + } + if requirement.kind == .minimumScore { + guard let minimum = requirement.minimum, (0...10_000).contains(minimum) else { + throw VeilStoryError.invalidBook + } + } + } + for effect in choice.effects { + guard !effect.value.isEmpty, effect.value.count <= 80 else { + throw VeilStoryError.invalidBook + } + if effect.kind == .score { + guard let amount = effect.amount, (-100...100).contains(amount) else { + throw VeilStoryError.invalidBook + } + } + } + frontier.append(choice.destination) + } + } + guard reachable.count == scenes.count else { throw VeilStoryError.unreachableScene } + } + + func scene(id: String) -> VeilStoryScene? { scenes.first { $0.id == id } } +} + +enum VeilStoryError: Error, Equatable { + case invalidBook, unreachableScene, unknownChoice, unavailableChoice, storyFinished, turnLimitReached +} + +struct VeilStoryDecision: Codable, Equatable, Sendable { + let fromScene: String + let choiceID: String + let toScene: String +} + +struct VeilStoryRun: Codable, Equatable, Sendable { + static let maximumTurns = 128 + + private(set) var sceneID: String + private(set) var flags: Set = [] + private(set) var items: Set = [] + private(set) var score = 0 + private(set) var history: [VeilStoryDecision] = [] + + init(book: VeilStoryBook) throws { + try book.validate() + sceneID = book.startingScene + } + + func availableChoices(in book: VeilStoryBook) -> [VeilStoryChoice] { + guard let scene = book.scene(id: sceneID) else { return [] } + return scene.choices.filter { choice in + choice.requirements.allSatisfy { $0.passes(self) } + } + } + + mutating func choose(_ choiceID: String, in book: VeilStoryBook) throws { + guard history.count < Self.maximumTurns else { throw VeilStoryError.turnLimitReached } + guard let scene = book.scene(id: sceneID) else { throw VeilStoryError.invalidBook } + guard scene.ending == nil else { throw VeilStoryError.storyFinished } + guard let choice = scene.choices.first(where: { $0.id == choiceID }) else { + throw VeilStoryError.unknownChoice + } + guard choice.requirements.allSatisfy({ $0.passes(self) }) else { + throw VeilStoryError.unavailableChoice + } + guard book.scene(id: choice.destination) != nil else { throw VeilStoryError.invalidBook } + // Change a copy, then commit atomically. Invalid external content must not partially mutate a run. + var next = self + for effect in choice.effects { + switch effect.kind { + case .gainItem: next.items.insert(effect.value) + case .loseItem: next.items.remove(effect.value) + case .setFlag: next.flags.insert(effect.value) + case .clearFlag: next.flags.remove(effect.value) + case .score: next.score = max(0, min(10_000, next.score + (effect.amount ?? 0))) + } + } + next.history.append(.init(fromScene: sceneID, choiceID: choiceID, toScene: choice.destination)) + next.sceneID = choice.destination + self = next + } + + static func replay(book: VeilStoryBook, decisions: [VeilStoryDecision]) throws -> VeilStoryRun { + guard decisions.count <= maximumTurns else { throw VeilStoryError.turnLimitReached } + var run = try VeilStoryRun(book: book) + for decision in decisions { + guard run.sceneID == decision.fromScene else { throw VeilStoryError.invalidBook } + try run.choose(decision.choiceID, in: book) + guard run.sceneID == decision.toScene else { throw VeilStoryError.invalidBook } + } + return run + } +} + +/// Original, locally authored demo stories. No model, network, image or audio asset dependency. +enum VeilStoryLibrary { + static let books: [VeilStoryBook] = [nightTrain, observatory, lighthouse] + private static func e(_ k: VeilStoryEffect.Kind, _ v: String, _ n: Int? = nil) -> VeilStoryEffect { + .init(kind: k, value: v, amount: n) + } + private static func req(_ k: VeilStoryCondition.Kind, _ v: String, _ n: Int? = nil) -> VeilStoryCondition { + .init(kind: k, value: v, minimum: n) + } + private static func c(_ id: String, _ text: String, _ dest: String, + requirements: [VeilStoryCondition] = [], + effects: [VeilStoryEffect] = []) -> VeilStoryChoice { + .init(id: id, label: text, destination: dest, requirements: requirements, effects: effects) + } + private static func s(_ id: String, _ title: String, _ text: String, + _ choices: [VeilStoryChoice] = [], ending: String? = nil) -> VeilStoryScene { + .init(id: id, title: title, narrative: text, choices: choices, ending: ending) + } + + static let nightTrain = VeilStoryBook( + id: "night-train", title: "雨夜末班电车", description: "从一张遗落车票开始的东京夜行故事。", + startingScene: "platform", scenes: [ + s("platform", "末班站台", "站台广播已经停止。自动贩卖机旁留着一张去往旧天文台的车票。", [ + c("ticket", "拾起车票,登上电车", "carriage", effects: [e(.gainItem, "ticket"), e(.score, "curiosity", 10)]), + c("wait", "在雨棚下等待", "morning") + ]), + s("carriage", "空荡车厢", "车窗倒映着陌生的星图。有人在座位下留下一个信封。", [ + c("envelope", "拆开信封查看", "map", effects: [e(.setFlag, "readLetter"), e(.score, "observation", 15)]), + c("window", "记住窗外的地标", "bridge", effects: [e(.gainItem, "landmark")]) + ]), + s("map", "星图留言", "信里写着:最后一班电车会为懂得观察的人停车。", [ + c("follow", "依据车票去天文台", "observatory", requirements: [req(.hasItem, "ticket")]), + c("ignore", "收起信件,在下一站下车", "morning") + ]), + s("bridge", "跨河铁桥", "桥外亮着旧钟楼的灯。车速开始放缓。", [ + c("recognize", "根据地标按下停靠铃", "observatory", requirements: [req(.hasItem, "landmark")]), + c("continue", "乘车到终点", "morning") + ]), + s("observatory", "无人天文台", "旧望远镜依然指向夜空。等雨停时,云层裂开了一道星光。", [], ending: "隐藏结局:夜空的坐标"), + s("morning", "第一班晨光", "新一天开始了。那张车票仿佛只是雨夜的一场梦。", [], ending: "普通结局:雨后清晨") + ]) + static let observatory = VeilStoryBook( + id: "observatory", title: "失联天文台", description: "科幻推理:修复信标,解开星云的秘密。", + startingScene: "signal", scenes: [ + s("signal", "异常信号", "轨道站接收到了几十年前的观测报告。电力正在下降。", [ + c("repair", "先修复备用电源", "power", effects: [e(.gainItem, "cell"), e(.score, "repair", 20)]), + c("decode", "优先破译星图", "map", effects: [e(.setFlag, "decoded")]) + ]), + s("power", "备用电源", "应急照明恢复了。你找到了通往信标的通道。", [ + c("beacon", "装入电池,启动信标", "safe", requirements: [req(.hasItem, "cell")]), + c("explore", "继续检查未知信号", "map") + ]), + s("map", "远方坐标", "星图显示信号来自无人探测器,而不是未知文明。", [ + c("recall", "发送召回指令", "discovery", effects: [e(.score, "discovery", 30)]), + c("backup", "返回电力舱", "power", effects: [e(.gainItem, "cell")]) + ]), + s("safe", "信标重启", "轨道站恢复通信,远方地平线上升起温柔的蓝色光晕。", [], ending: "结局:回家的频率"), + s("discovery", "探测器回声", "一份尘封数据被送回地球,补上了人类失去的星图。", [], ending: "结局:未完成的观测") + ]) + static let lighthouse = VeilStoryBook( + id: "lighthouse", title: "海岸灯塔", description: "治愈系探索:用有限的线索照亮一座离岛。", + startingScene: "dock", scenes: [ + s("dock", "离岛码头", "潮水正在退去。灯塔已经整夜没有亮灯。", [ + c("workshop", "先检查维修棚", "workshop", effects: [e(.gainItem, "lamp")]), + c("hill", "径直登上山坡", "hill") + ]), + s("workshop", "旧维修棚", "你找到一只还能用的灯泡,墙上贴着维护记录。", [ + c("climb", "带着灯泡去灯塔", "tower", requirements: [req(.hasItem, "lamp")]), + c("return", "返回码头休息", "quiet") + ]), + s("hill", "山顶岔路", "灯塔就在眼前,但门口缺了一件替换零件。", [ + c("repair", "回维修棚取零件", "workshop"), + c("rest", "坐下欣赏海平面", "quiet") + ]), + s("tower", "重燃灯塔", "灯光划过黑色海面,远处的船只终于看到了方向。", [], ending: "结局:照亮归途"), + s("quiet", "海风与清晨", "你听见海鸥的声音。远处仍有船只平安驶过。", [], ending: "结局:等待日出") + ]) +} diff --git a/VeilLink/Entertainment/VeilStoryLabView.swift b/VeilLink/Entertainment/VeilStoryLabView.swift new file mode 100644 index 00000000..f4bb5c5b --- /dev/null +++ b/VeilLink/Entertainment/VeilStoryLabView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// Candidate standalone screen: NOT wired into GameLobbyView until P0 touch fixes are accepted. +struct VeilStoryLabView: View { + @State private var selectedBook: VeilStoryBook? + @State private var run: VeilStoryRun? + @State private var errorText: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("VEIL TALES · 离线互动剧场") + .font(.title2.bold()) + .accessibilityAddTraits(.isHeader) + Text("三部原创短篇 · 分支决策 · 道具与线索 · 多结局 · 可重玩") + .font(.subheadline) + .foregroundColor(.secondary) + if let book = selectedBook, let currentRun = run, + let scene = book.scene(id: currentRun.sceneID) { + sessionSection(book: book, run: currentRun, scene: scene) + } else { + ForEach(VeilStoryLibrary.books) { book in + Button { + do { + let newRun = try VeilStoryRun(book: book) + selectedBook = book + run = newRun + errorText = nil + } catch { errorText = "剧本校验失败,无法开始。" } + } label: { + VStack(alignment: .leading, spacing: 7) { + Text(book.title).font(.headline) + Text(book.description).font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.secondary.opacity(0.10)) + .cornerRadius(16) + } + .buttonStyle(.plain) + } + } + if let errorText = errorText { + Text(errorText).foregroundColor(.red).font(.footnote) + } + } + .padding(16) + } + .navigationTitle("互动剧场") + } + + private func sessionSection(book: VeilStoryBook, run currentRun: VeilStoryRun, + scene: VeilStoryScene) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(book.title).font(.headline) + Spacer() + Text("探索分 \(currentRun.score)") + .font(.caption.monospacedDigit()) + } + Text(scene.title).font(.title3.bold()) + Text(scene.narrative).font(.body).fixedSize(horizontal: false, vertical: true) + if !currentRun.items.isEmpty { + Text("线索:\(currentRun.items.sorted().joined(separator: " · "))") + .font(.caption).foregroundColor(.secondary) + } + if let ending = scene.ending { + Text(ending).font(.headline) + Button("再玩一次") { + self.run = try? VeilStoryRun(book: book) + } + .buttonStyle(.borderedProminent) + } else { + ForEach(currentRun.availableChoices(in: book)) { choice in + Button(choice.label) { + guard var snapshot = self.run else { return } + do { + try snapshot.choose(choice.id, in: book) + self.run = snapshot + } catch { + errorText = "当前选项不可用,请重新选择。" + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + HStack { + Button("返回剧本列表") { selectedBook = nil; self.run = nil } + Spacer() + Text("第 \(currentRun.history.count) 步") + .foregroundColor(.secondary).font(.caption) + } + } + .padding(16) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(16) + } +} diff --git a/VeilLink/Resources/Info.plist b/VeilLink/Resources/Info.plist index 05a49c68..b211ffc1 100644 --- a/VeilLink/Resources/Info.plist +++ b/VeilLink/Resources/Info.plist @@ -43,7 +43,7 @@ NSFaceIDUsageDescription VeilLink 使用 Face ID 在本机解锁应用;生物识别数据由系统处理,VeilLink 不读取或保存。 NSCameraUsageDescription - 灵核视频对话可在本机使用前置摄像头生成低频视觉摘要;原始视频不会上传。 + 仅在你主动打开扫码时用摄像头识别远程配对二维码;灵核视频对话也可在你选择时使用摄像头。扫码图像和灵核原始视频不会上传。 NSMicrophoneUsageDescription 用于发送加密语音消息、本地蓝牙对讲机与灵核的本机语音输入;不支持离线识别时不会自动改用云端。 NSSpeechRecognitionUsageDescription diff --git a/VeilLink/Storage/BackupManager.swift b/VeilLink/Storage/BackupManager.swift index 4d0ab52c..fdcf2e51 100644 --- a/VeilLink/Storage/BackupManager.swift +++ b/VeilLink/Storage/BackupManager.swift @@ -131,6 +131,8 @@ final class BackupManager { try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } try validateArchiveBeforeExtraction(archiveURL) + let preflight = VeilBackupArchiveInspector.inspect(archiveURL, verifyAllCRC: false) + guard preflight.permittedToRestore else { throw BackupError.invalidPackage } try fileManager.unzipItem(at: archiveURL, to: root) try validateExtractedTree(root) diff --git a/VeilLink/Storage/VeilBackupArchiveInspector.swift b/VeilLink/Storage/VeilBackupArchiveInspector.swift new file mode 100644 index 00000000..5a0a06a3 --- /dev/null +++ b/VeilLink/Storage/VeilBackupArchiveInspector.swift @@ -0,0 +1,106 @@ +import Foundation +import ZIPFoundation + +/// Runs on a non-main executor; never extracts the archive to disk, changes +/// SQLite or asks for backup credentials. This is not a decryption check. +enum VeilBackupArchiveInspector { + private struct ManifestHeader: Decodable { + let format: String + let version: Int + let createdAt: Date + let identityID: String + let salt: Data + let iterations: Int + } + + static func inspect(_ url: URL, verifyAllCRC: Bool) -> VeilBackupPreflightPolicy.Report { + let policy = VeilBackupPreflightPolicy.self + let empty = policy.evaluate([], zipBytes: 0) + guard url.isFileURL else { return empty.appending([.damagedArchive]) } + #if canImport(Darwin) + let securityScoped = url.startAccessingSecurityScopedResource() + defer { if securityScoped { url.stopAccessingSecurityScopedResource() } } + #endif + do { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, let rawSize = values.fileSize, rawSize > 0 else { + return empty.appending([.damagedArchive]) + } + let byteCount = UInt64(rawSize) + if byteCount > policy.maxArchiveBytes { + return policy.evaluate([], zipBytes: byteCount) + } + let archive = try Archive(url: url, accessMode: .read) + var descriptors: [VeilBackupPreflightPolicy.EntryInfo] = [] + descriptors.reserveCapacity(32) + var manifestEntry: Entry? + for entry in archive { + if descriptors.count > policy.maxEntryCount { break } + let kind: VeilBackupPreflightPolicy.Kind + switch entry.type { + case .file: kind = .file + case .directory: kind = .directory + case .symlink: kind = .symlink + @unknown default: kind = .other + } + descriptors.append(.init(path: entry.path, kind: kind, + compressedBytes: entry.compressedSize, + expandedBytes: entry.uncompressedSize)) + if entry.path == "manifest.json", kind == .file { manifestEntry = entry } + } + var report = policy.evaluate(descriptors, zipBytes: byteCount) + guard report.permittedToRestore else { return report } + guard let manifestEntry, + manifestEntry.uncompressedSize <= policy.maxManifestBytes else { + return report.appending([.invalidManifest]) + } + var manifestBytes = Data() + manifestBytes.reserveCapacity(Int(manifestEntry.uncompressedSize)) + let manifestCRC = try archive.extract(manifestEntry, bufferSize: 16 * 1024, + skipCRC32: false) { bytes in + guard manifestBytes.count <= Int(policy.maxManifestBytes) - bytes.count else { + throw VeilBackupPreflightError.invalidContent + } + manifestBytes.append(bytes) + } + guard manifestCRC == manifestEntry.checksum, + manifestBytes.count == Int(manifestEntry.uncompressedSize) else { + return report.appending([.crcMismatch]) + } + let header: ManifestHeader + do { header = try JSONDecoder().decode(ManifestHeader.self, from: manifestBytes) } + catch { return report.appending([.invalidManifest]) } + guard header.format == "VeilLinkBackup", [1, 2].contains(header.version), + !header.identityID.isEmpty, header.identityID.utf8.count <= 128, + (16...64).contains(header.salt.count), + (100_000...1_000_000).contains(header.iterations) else { + return report.appending([.invalidManifest]) + } + report = report.appending([], version: header.version, createdAt: header.createdAt) + if verifyAllCRC { + do { + for entry in archive where entry.type == .file { + var received: UInt64 = 0 + let computed = try archive.extract(entry, bufferSize: 32 * 1024, + skipCRC32: false) { data in + let (next, overflow) = received.addingReportingOverflow(UInt64(data.count)) + guard !overflow, next <= entry.uncompressedSize else { + throw VeilBackupPreflightError.invalidContent + } + received = next + } + guard computed == entry.checksum, received == entry.uncompressedSize else { + return report.appending([.crcMismatch]) + } + } + } catch { return report.appending([.crcMismatch]) } + report = report.appending([], crcVerified: true) + } + return report + } catch { + return empty.appending([.damagedArchive]) + } + } +} + +private enum VeilBackupPreflightError: Error { case invalidContent } diff --git a/VeilLink/Storage/VeilBackupPreflightPolicy.swift b/VeilLink/Storage/VeilBackupPreflightPolicy.swift new file mode 100644 index 00000000..ef5a68f9 --- /dev/null +++ b/VeilLink/Storage/VeilBackupPreflightPolicy.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Read-only metadata policy for existing VeilLink ZIP backup format v1/v2. +/// The inspector owns ZIPFoundation I/O; this policy never reads or writes files. +enum VeilBackupPreflightPolicy { + static let maxArchiveBytes: UInt64 = 200 * 1024 * 1024 + static let maxExpandedBytes: UInt64 = 350 * 1024 * 1024 + static let maxEntryCount = 10_000 + static let maxNameBytes = 1024 + static let maxManifestBytes: UInt64 = 128 * 1024 + static let maxExpansionRatio: UInt64 = 1_500 + static let expansionCheckFloor: UInt64 = 1 * 1024 * 1024 + static let requiredFiles: Set = ["manifest.json", "database.enc", "storage-key.enc", "identity.enc"] + + enum Kind: String, Sendable { case file, directory, symlink, other } + + struct EntryInfo: Equatable, Sendable { + let path: String + let kind: Kind + let compressedBytes: UInt64 + let expandedBytes: UInt64 + } + + enum Problem: String, CaseIterable, Sendable { + case emptyArchive, archiveTooLarge, tooManyEntries, tooLargeExpanded + case unsafePath, disallowedPath, duplicatePath, symlink, oversizedName + case implausibleCompression, missingRequiredFile, invalidEntrySize + case damagedArchive, invalidManifest, crcMismatch + + var explanation: String { + switch self { + case .emptyArchive: return "归档为空或不是有效 ZIP。" + case .archiveTooLarge: return "归档体积超过 VeilLink 当前 200 MiB 导入上限。" + case .tooManyEntries: return "ZIP 条目数超过 10,000。" + case .tooLargeExpanded: return "声明的总解压体积超过 350 MiB。" + case .unsafePath: return "检测到危险的归档路径或路径分隔符。" + case .disallowedPath: return "包含现有备份格式不支持的文件或目录。" + case .duplicatePath: return "检测到重复条目或路径大小写/Unicode 碰撞。" + case .symlink: return "ZIP 包含符号链接或未知文件类型。" + case .oversizedName: return "某条目名称超过 1,024 UTF-8 字节。" + case .implausibleCompression: return "发现异常压缩比,可能导致解压资源耗尽。" + case .missingRequiredFile: return "缺少 manifest.json 或加密数据库、存储密钥、身份文件。" + case .invalidEntrySize: return "发现不合理或溢出的文件大小。" + case .damagedArchive: return "ZIP 文件结构损坏,无法打开或读取。" + case .invalidManifest: return "备份清单损坏或版本不在现有支持范围内。" + case .crcMismatch: return "归档条目 CRC 不匹配或读取失败。" + } + } + } + + struct Report: Equatable, Sendable { + let zipBytes: UInt64 + let compressedContentBytes: UInt64 + let expandedBytes: UInt64 + let entries: Int + let attachments: Int + let problems: [Problem] + let crcVerified: Bool + let version: Int? + let createdAt: Date? + + var permittedToRestore: Bool { problems.isEmpty } + var safetyLabel: String { + if !permittedToRestore { return "检查未通过:请勿直接恢复" } + return crcVerified ? "结构与 CRC 已核对:仍需密码和数据库验证" : "结构预检通过:内容 CRC 尚未逐项验证" + } + func appending(_ newProblems: [Problem], crcVerified: Bool? = nil, + version: Int? = nil, createdAt: Date? = nil) -> Report { + var merged = problems + for p in newProblems where !merged.contains(p) { merged.append(p) } + return .init(zipBytes: zipBytes, compressedContentBytes: compressedContentBytes, + expandedBytes: expandedBytes, entries: entries, attachments: attachments, + problems: merged, crcVerified: crcVerified ?? self.crcVerified, + version: version ?? self.version, createdAt: createdAt ?? self.createdAt) + } + } + + /// Path allowlist must stay compatible with BackupManager's current v1/v2 layout. + static func isAllowedPath(_ name: String, kind: Kind) -> Bool { + guard !name.isEmpty, !name.hasPrefix("/"), !name.hasPrefix("\\"), + !name.contains("\\"), !name.contains(":"), !name.contains("\0"), + name == name.precomposedStringWithCanonicalMapping else { return false } + let components = name.split(separator: "/", omittingEmptySubsequences: false) + guard !components.isEmpty, + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && + !$0.hasSuffix(" ") && !$0.hasSuffix(".") && + !$0.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) }) || + (kind == .directory && name == "attachments/") else { return false } + if kind == .directory { return name == "attachments/" } + guard kind == .file else { return false } + if requiredFiles.contains(name) { return true } + guard components.count == 2, components[0] == "attachments", + components[1].hasSuffix(".bin") else { return false } + let stem = String(components[1].dropLast(4)) + return UUID(uuidString: stem) != nil + } + + /// Reject case/normalization aliases even if the user's file system is case sensitive. + static func collisionKey(_ path: String) -> String { + path.precomposedStringWithCanonicalMapping.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) + } + + static func evaluate(_ entries: [EntryInfo], zipBytes: UInt64) -> Report { + var problems: [Problem] = [] + func flag(_ p: Problem) { if !problems.contains(p) { problems.append(p) } } + if zipBytes == 0 || entries.isEmpty { flag(.emptyArchive) } + if zipBytes > maxArchiveBytes { flag(.archiveTooLarge) } + if entries.count > maxEntryCount { flag(.tooManyEntries) } + + var seen: Set = [] + var files: Set = [] + var totalExpanded: UInt64 = 0 + var totalCompressed: UInt64 = 0 + var attachments = 0 + for entry in entries.prefix(maxEntryCount + 1) { + if entry.kind == .symlink || entry.kind == .other { flag(.symlink) } + if entry.path.utf8.count > maxNameBytes { flag(.oversizedName) } + guard isAllowedPath(entry.path, kind: entry.kind) else { + flag(entry.path.contains("..") || entry.path.contains("\\") || entry.path.hasPrefix("/") ? .unsafePath : .disallowedPath) + continue + } + if !seen.insert(collisionKey(entry.path)).inserted { flag(.duplicatePath) } + if entry.kind == .directory { continue } + files.insert(entry.path) + if entry.path.hasPrefix("attachments/") { attachments += 1 } + let (e, eOverflow) = totalExpanded.addingReportingOverflow(entry.expandedBytes) + let (c, cOverflow) = totalCompressed.addingReportingOverflow(entry.compressedBytes) + if eOverflow || cOverflow { flag(.invalidEntrySize) } + else { + totalExpanded = e + totalCompressed = c + if totalExpanded > maxExpandedBytes { flag(.tooLargeExpanded) } + } + if entry.expandedBytes > 0 && entry.compressedBytes == 0 { + flag(.implausibleCompression) + } else if entry.expandedBytes >= expansionCheckFloor, + entry.compressedBytes > 0, + entry.expandedBytes / entry.compressedBytes > maxExpansionRatio { + flag(.implausibleCompression) + } + } + if totalCompressed > zipBytes { flag(.invalidEntrySize) } + if !requiredFiles.isSubset(of: files) { flag(.missingRequiredFile) } + return .init(zipBytes: zipBytes, compressedContentBytes: totalCompressed, + expandedBytes: totalExpanded, entries: entries.count, + attachments: attachments, problems: problems, + crcVerified: false, version: nil, createdAt: nil) + } +} diff --git a/VeilLink/UI/AfterglowConvoyLabView.swift b/VeilLink/UI/AfterglowConvoyLabView.swift index 0ca1d0f6..26537ef9 100644 --- a/VeilLink/UI/AfterglowConvoyLabView.swift +++ b/VeilLink/UI/AfterglowConvoyLabView.swift @@ -110,9 +110,25 @@ struct AfterglowConvoyLabView: View { model.configure(reduceMotion: reduceMotion) model.setPaused(false) } - .onDisappear { model.setPaused(true) } - .onChange(of: scenePhase) { model.setPaused($0 != .active) } - .onChange(of: showsGuide) { model.setPaused($0) } + .onDisappear { + model.steer(0) + model.setShielding(false) + model.setPaused(true) + } + .onChange(of: scenePhase) { phase in + if phase != .active { + model.steer(0) + model.setShielding(false) + } + model.setPaused(phase != .active) + } + .onChange(of: showsGuide) { showing in + if showing { + model.steer(0) + model.setShielding(false) + } + model.setPaused(showing) + } .onChange(of: reduceMotion) { model.configure(reduceMotion: $0) } } diff --git a/VeilLink/UI/AshHarborLabView.swift b/VeilLink/UI/AshHarborLabView.swift index f40c5088..3b6ce7b5 100644 --- a/VeilLink/UI/AshHarborLabView.swift +++ b/VeilLink/UI/AshHarborLabView.swift @@ -95,6 +95,7 @@ struct AshHarborLabView: View { @State private var throttle = 0.58 @State private var searchlight = true @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.scenePhase) private var scenePhase var body: some View { GeometryReader { proxy in @@ -130,6 +131,16 @@ struct AshHarborLabView: View { .onChange(of: reduceMotion) { model.setReduceMotion($0) } + .onDisappear { releaseTouchInput() } + .onChange(of: scenePhase) { phase in + if phase != .active { releaseTouchInput() } + } + } + + private func releaseTouchInput() { + model.setRudder(0) + model.setRescuing(false) + model.setThrottle(0) } private var hud: some View { diff --git a/VeilLink/UI/BlackoutDistrictLabView.swift b/VeilLink/UI/BlackoutDistrictLabView.swift index 03bd5573..d57e662e 100644 --- a/VeilLink/UI/BlackoutDistrictLabView.swift +++ b/VeilLink/UI/BlackoutDistrictLabView.swift @@ -117,6 +117,7 @@ struct BlackoutDistrictLabView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.scenePhase) private var scenePhase @State private var controlOrigin: CGPoint? @@ -171,6 +172,10 @@ struct BlackoutDistrictLabView: View { $0 ) } + .onDisappear { stopTouchInput() } + .onChange(of: scenePhase) { phase in + if phase != .active { stopTouchInput() } + } .navigationTitle( "熄灯街区" ) @@ -179,6 +184,12 @@ struct BlackoutDistrictLabView: View { ) } + private func stopTouchInput() { + controlOrigin = nil + model.stopDriving() + model.setRepairing(false) + } + private var hud: some View { VStack(spacing: 8) { HStack(spacing: 8) { diff --git a/VeilLink/UI/LatticeClashLabView.swift b/VeilLink/UI/LatticeClashLabView.swift new file mode 100644 index 00000000..d9cca34b --- /dev/null +++ b/VeilLink/UI/LatticeClashLabView.swift @@ -0,0 +1,371 @@ +import SwiftUI + +struct LatticeClashLabView: View { + @State private var state = LatticeClashState() + + private var columns: [GridItem] { + Array( + repeating: GridItem(.flexible(), spacing: 5), + count: LatticeClashState.size + ) + } + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.055, green: 0.065, blue: 0.085), + Color(red: 0.11, green: 0.12, blue: 0.15) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + header + scoreDeck + board + turnDeck + rulesDeck + } + .padding(16) + .frame(maxWidth: 620) + .frame(maxWidth: .infinity) + } + } + .navigationTitle("晶格争夺") + .navigationBarTitleDisplayMode(.inline) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("LATTICE CLASH") + .font(.system(.caption, design: .monospaced).weight(.bold)) + .tracking(1.4) + .foregroundColor(Color.cyan.opacity(0.86)) + Text("晶格争夺 · Game Lab") + .font(.title3.weight(.semibold)) + .foregroundColor(.white) + } + Spacer() + Text("7×7") + .font(.caption.bold().monospacedDigit()) + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.07)) + .clipShape(Capsule()) + } + + Text("从两侧锚点扩张晶格。新节点若让敌方节点同时受到至少 3 个友方邻点支撑,会触发确定性链式转化。先控制 25 格者获胜。") + .font(.caption) + .foregroundColor(.white.opacity(0.62)) + .fixedSize(horizontal: false, vertical: true) + } + .panel() + } + + private var scoreDeck: some View { + HStack(spacing: 10) { + scoreCard( + title: "YOU", + value: state.count(for: .host), + color: .cyan, + active: state.currentPlayer == .host && !state.isFinished + ) + scoreCard( + title: "BOT", + value: state.count(for: .guest), + color: .orange, + active: state.currentPlayer == .guest && !state.isFinished + ) + VStack(spacing: 4) { + Text("TURN") + .font(.caption2.bold().monospaced()) + .foregroundColor(.white.opacity(0.45)) + Text("\(state.turn)") + .font(.title3.bold().monospacedDigit()) + .foregroundColor(.white) + Text("/ \(LatticeClashState.maxTurns)") + .font(.caption2.monospacedDigit()) + .foregroundColor(.white.opacity(0.4)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.black.opacity(0.22)) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + .panel() + } + + private var board: some View { + LazyVGrid(columns: columns, spacing: 5) { + ForEach(0.. some View { + let cell = state.cell(at: index) + let legal = state.isLegal( + move: LatticeClashMove(index: index), + actor: .host + ) + + return Button { + commitHumanMove(index: index) + } label: { + ZStack { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(cellBackground(cell: cell, legal: legal)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke( + state.lastMove == index + ? Color.white.opacity(0.8) + : Color.white.opacity(0.055), + lineWidth: state.lastMove == index ? 1.5 : 0.8 + ) + ) + + switch cell { + case .host: + node(color: .cyan) + case .guest: + node(color: .orange) + case .empty: + if legal { + Circle() + .fill(Color.cyan.opacity(0.32)) + .frame(width: 7, height: 7) + } + } + } + .aspectRatio(1, contentMode: .fit) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!legal || state.isFinished) + .accessibilityLabel(cellAccessibility(index: index, cell: cell, legal: legal)) + } + + private func node(color: Color) -> some View { + Circle() + .fill( + RadialGradient( + colors: [Color.white.opacity(0.9), color], + center: .topLeading, + startRadius: 0, + endRadius: 18 + ) + ) + .padding(7) + .shadow(color: color.opacity(0.45), radius: 5) + } + + private func cellBackground(cell: LatticeClashCell, legal: Bool) -> Color { + switch cell { + case .host: + return Color.cyan.opacity(0.09) + case .guest: + return Color.orange.opacity(0.09) + case .empty: + return legal ? Color.cyan.opacity(0.06) : Color.white.opacity(0.025) + } + } + + private func scoreCard( + title: String, + value: Int, + color: Color, + active: Bool + ) -> some View { + VStack(spacing: 4) { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + Text(title) + .font(.caption2.bold().monospaced()) + .foregroundColor(.white.opacity(0.5)) + } + Text("\(value)") + .font(.title2.bold().monospacedDigit()) + .foregroundColor(.white) + Text(active ? "ACTIVE" : "STANDBY") + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundColor(active ? color : Color.white.opacity(0.28)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background(Color.black.opacity(active ? 0.28 : 0.2)) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + + private var statusColor: Color { + if state.isFinished { + return state.isDraw ? .yellow : (state.winner == .host ? .cyan : .orange) + } + return state.currentPlayer == .host ? .cyan : .orange + } + + private var statusText: String { + if state.isDraw { + return "晶格锁定 · 平局" + } + if let winner = state.winner { + return winner == .host ? "多数控制完成 · 你获胜" : "多数控制完成 · BOT 获胜" + } + return state.currentPlayer == .host ? "你的扩张窗口" : "BOT 正在计算" + } + + private func commitHumanMove(index: Int) { + var next = state + guard next.apply( + move: LatticeClashMove(index: index), + actor: .host + ) else { + return + } + + var safety = 0 + while !next.isFinished, + next.currentPlayer == .guest, + safety < LatticeClashState.maxTurns { + guard let move = LatticeClashBot.chooseMove(in: next, actor: .guest), + next.apply(move: move, actor: .guest) else { + break + } + safety += 1 + } + + withAnimation(.easeInOut(duration: 0.18)) { + state = next + } + } + + private func cellAccessibility( + index: Int, + cell: LatticeClashCell, + legal: Bool + ) -> String { + let coordinate = LatticeClashState.rowColumn(for: index) + let owner: String + switch cell { + case .empty: owner = legal ? "空格,可落子" : "空格" + case .host: owner = "你的节点" + case .guest: owner = "BOT 节点" + } + return "第 \(coordinate.row + 1) 行,第 \(coordinate.column + 1) 列,\(owner)" + } +} + +private struct LatticeClashControlButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.caption.weight(.semibold)) + .foregroundColor(.black.opacity(0.82)) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background( + LinearGradient( + colors: [ + Color.cyan.opacity(configuration.isPressed ? 0.62 : 0.94), + Color.blue.opacity(configuration.isPressed ? 0.55 : 0.78) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .scaleEffect(configuration.isPressed ? 0.985 : 1) + } +} + +private extension View { + func panel() -> some View { + self + .padding(13) + .background( + LinearGradient( + colors: [ + Color.white.opacity(0.075), + Color.white.opacity(0.035) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 17, style: .continuous) + .stroke(Color.white.opacity(0.07), lineWidth: 0.8) + ) + } +} diff --git a/VeilLink/UI/MiniGameViews.swift b/VeilLink/UI/MiniGameViews.swift index 73d8c968..dc2077f5 100644 --- a/VeilLink/UI/MiniGameViews.swift +++ b/VeilLink/UI/MiniGameViews.swift @@ -1,6 +1,7 @@ import SwiftUI import Combine import Foundation +import UIKit struct MiniGameHubView: View { @ObservedObject var model: AppModel @@ -1418,6 +1419,56 @@ private struct MiniGameStatusHeader: View { } } +/// A tap-only UIKit bridge for board coordinates. The old zero-distance +/// DragGesture hijacked scrolling and could submit moves on drag release. +/// This view never recognizes a drag and does not cancel ancestor scroll touches. +private struct VeilBoardTapSurface: UIViewRepresentable { + let enabled: Bool + let onTap: (CGPoint) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onTap: onTap) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.cancelsTouchesInView = false + tap.delegate = context.coordinator + view.addGestureRecognizer(tap) + return view + } + + func updateUIView(_ view: UIView, context: Context) { + context.coordinator.onTap = onTap + view.isUserInteractionEnabled = enabled + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onTap: (CGPoint) -> Void + + init(onTap: @escaping (CGPoint) -> Void) { + self.onTap = onTap + } + + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, let view = recognizer.view else { return } + onTap(recognizer.location(in: view)) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + } +} + struct GomokuBoardView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion let state: GomokuState @@ -1473,24 +1524,18 @@ struct GomokuBoardView: View { } } - if enabled { - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onEnded { gesture in - let col = Int(round((gesture.location.x - margin) / step)) - let row = Int(round((gesture.location.y - margin) / step)) - guard (0.. 0 else { return } + let col = Int(round((location.x - margin) / step)) + let row = Int(round((location.y - margin) / step)) + guard (0.. some Gesture { diff --git a/VeilLink/UI/TacticalV2/TacticalLandscapeShellV2.swift b/VeilLink/UI/TacticalV2/TacticalLandscapeShellV2.swift index 1dae9898..6ff37056 100644 --- a/VeilLink/UI/TacticalV2/TacticalLandscapeShellV2.swift +++ b/VeilLink/UI/TacticalV2/TacticalLandscapeShellV2.swift @@ -122,6 +122,7 @@ struct TacticalLandscapeShellV2: View { .background(.black.opacity(0.34)) .clipShape(RoundedRectangle(cornerRadius: 6)) .padding(10) + .allowsHitTesting(false) } } .clipped() @@ -235,7 +236,8 @@ struct TacticalLandscapeShellV2: View { } private func mapGesture(size: CGSize) -> some Gesture { - DragGesture(minimumDistance: 1) + // Coordinate selection must respond to a stationary tap on iOS 15. + DragGesture(minimumDistance: 0) .onChanged { value in let normalized = normalizedPoint(value.location, size: size) guard let target = TacticalV2.MapGeometryV2.nearestCell( diff --git a/VeilLink/UI/ToolCenterView.swift b/VeilLink/UI/ToolCenterView.swift index 537417b2..d05b78ec 100644 --- a/VeilLink/UI/ToolCenterView.swift +++ b/VeilLink/UI/ToolCenterView.swift @@ -66,6 +66,9 @@ struct VeilToolCenterView: View { } LazyVGrid(columns: [GridItem(.adaptive(minimum: 138), spacing: 10)], spacing: 10) { + if matches("v27 功能库 实验室 游戏 情绪 工具 feature library") { + toolLink("V27 功能库", detail: "统一搜索 · 按需加载", icon: "square.grid.3x3.fill", destination: VeilV27FeatureHubView()) + } if matches("手电筒信标 手电筒 信标 闪光灯 sos flashlight beacon") { toolLink("手电筒信标", detail: "常亮 · 脉冲 · SOS", icon: "flashlight.on.fill", destination: VeilBeaconView()) } @@ -1058,6 +1061,7 @@ struct VeilWalkieTalkieView: View { @State private var selectedPeerIDs = Set() @State private var pressed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.scenePhase) private var scenePhase init(model: AppModel) { self.model = model @@ -1083,10 +1087,22 @@ struct VeilWalkieTalkieView: View { .background(VeilAmbientBackground()) .navigationTitle("对讲机") .navigationBarTitleDisplayMode(.inline) - .onDisappear { - if radio.state == .transmitting || radio.state == .preparing { - radio.endTransmit() - } + .onDisappear { cancelPress() } + .onChange(of: scenePhase) { phase in + if phase != .active { cancelPress() } + } + .onChange(of: recipients) { peers in + if peers.isEmpty { cancelPress() } + } + .onChange(of: isReceiving) { receiving in + if receiving { cancelPress() } + } + } + + private func cancelPress() { + pressed = false + if radio.state == .transmitting || radio.state == .preparing { + radio.endTransmit() } } diff --git a/VeilLink/UI/VeilAdaptiveParticleLayer.swift b/VeilLink/UI/VeilAdaptiveParticleLayer.swift index 1b225221..ceda62bc 100644 --- a/VeilLink/UI/VeilAdaptiveParticleLayer.swift +++ b/VeilLink/UI/VeilAdaptiveParticleLayer.swift @@ -58,7 +58,7 @@ private struct VeilAdaptiveParticleModifier: ViewModifier { .accessibilityHidden(true) } } - .simultaneousGesture(dragGesture(budget: resolvedBudget)) + // Visual-only: never compete with Slider, scroll, game drag, or back gestures. .onChange(of: eventToken) { _ in guard resolvedBudget.burstCount > 0 else { return } emitBurst(at: nil) diff --git a/VeilLink/UI/VeilAetherLabView.swift b/VeilLink/UI/VeilAetherLabView.swift new file mode 100644 index 00000000..53b0286d --- /dev/null +++ b/VeilLink/UI/VeilAetherLabView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +/// Offline-only lab: every result is from explicitly entered or synthetic JSON. +/// Never label this as a live device scanner, cryptographic audit or mesh controller. +struct VeilAetherLabView: View { + @State private var query = "" + @State private var selected = 201 + @State private var json = VeilAetherEngine.exampleJSON + @State private var report: VeilAetherReport? + @State private var errorText = "" + @State private var onlyResults = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var choices: [VeilAetherFeature] { + let filter = query.trimmingCharacters(in: .whitespacesAndNewlines) + return VeilAetherEngine.catalog.filter { filter.isEmpty || $0.title.localizedCaseInsensitiveContains(filter) + || $0.domain.localizedCaseInsensitiveContains(filter) || String($0.number).contains(filter) } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 7) { + Text("AETHER LAB / 100 EXPERIMENTS") + .font(.caption.monospaced().weight(.bold)) + .foregroundColor(.secondary) + Text("百项离线稀有计算实验室").font(.title2.bold()) + Text("仅对手动输入的去标识化 JSON 运算。不会读取数据库、私钥、聊天内容或真实设备日志。") + .font(.footnote).foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(RoundedRectangle(cornerRadius: 15).fill(Color.secondary.opacity(0.10))) + + TextField("按编号、功能或分组检索", text: $query) + .textInputAutocapitalization(.never).disableAutocorrection(true) + .padding(11) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.secondary.opacity(0.08))) + .accessibilityIdentifier("aether.search") + + if !onlyResults { + VStack(alignment: .leading, spacing: 10) { + Text("选择实验").font(.headline) + ForEach(choices) { item in + Button { + selected = item.number + report = nil + errorText = "" + } label: { + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%03d", item.number)).font(.caption.monospacedDigit()) + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.subheadline.weight(.semibold)) + Text(item.domain).font(.caption2).foregroundColor(.secondary) + } + Spacer() + if selected == item.number { Image(systemName: "checkmark.circle.fill") } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8).padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(RoundedRectangle(cornerRadius: 10).fill(selected == item.number ? Color.accentColor.opacity(0.13) : Color.secondary.opacity(0.05))) + .accessibilityIdentifier("aether.feature.\(item.number)") + } + } + } + + Toggle("收起功能目录", isOn: $onlyResults) + Text("输入 JSON(≤32 KiB)").font(.headline) + TextEditor(text: $json) + .font(.system(size: 12, design: .monospaced)) + .frame(height: 165) + .padding(6) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.2))) + .accessibilityIdentifier("aether.json") + HStack { + Button("恢复样本") { + json = VeilAetherEngine.exampleJSON + report = nil + errorText = "" + }.buttonStyle(.bordered) + Spacer() + Button("运行选中功能") { evaluate() } + .buttonStyle(.borderedProminent) + .disabled(json.utf8.count > 32768) + .accessibilityIdentifier("aether.run") + } + if !errorText.isEmpty { + Text(errorText).foregroundColor(.red).font(.footnote) + .accessibilityIdentifier("aether.error") + } + if let report { + VStack(alignment: .leading, spacing: 8) { + Text("\(report.featureID) · \(report.title)").font(.headline) + Text(report.output).font(.body.monospaced()) + .textSelection(.enabled) + Text(report.note).font(.caption).foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.10))) + .accessibilityIdentifier("aether.result") + } + Text("实验性质:本地人工样本 · 原创算法候选 · 不代表 100 项真实蓝牙/游戏服务已上线。") + .font(.caption2).foregroundColor(.secondary) + } + .padding(14) + } + .navigationTitle("AetherLab 100") + .navigationBarTitleDisplayMode(.inline) + } + + private func evaluate() { + do { + report = try VeilAetherEngine.run(selected, json: json) + errorText = "" + } catch { + report = nil + errorText = error.localizedDescription + } + } +} diff --git a/VeilLink/UI/VeilBackupPreflightView.swift b/VeilLink/UI/VeilBackupPreflightView.swift new file mode 100644 index 00000000..e904dfd2 --- /dev/null +++ b/VeilLink/UI/VeilBackupPreflightView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Standalone, deliberately separate from irreversible restore UI. +struct VeilBackupPreflightView: View { + @State private var presentingPicker = false + @State private var deepCRC = false + @State private var result: VeilBackupPreflightPolicy.Report? + @State private var working = false + @State private var generation = UUID() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("备份 ZIP 安全体检") + .font(.title3.bold()) + Text("选择备份文件后,只分析 ZIP 目录、清单及可选的 CRC 校验。不要求密码,不解密,不提取至磁盘,不修改现有聊天和密钥。") + .font(.footnote).foregroundColor(.secondary) + Toggle("完整校验全部文件 CRC(可能较慢)", isOn: $deepCRC) + .disabled(working) + Button { + presentingPicker = true + } label: { + Label("从文件选择备份 ZIP", systemImage: "doc.zipper") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(working) + if working { ProgressView("只读检查中…") } + if let result { + VStack(alignment: .leading, spacing: 10) { + Text(result.safetyLabel) + .font(.headline) + .foregroundColor(result.permittedToRestore ? .green : .red) + Text("条目 \(result.entries) · 附件 \(result.attachments)") + Text("压缩归档 \(ByteCountFormatter.string(fromByteCount: Int64(result.zipBytes), countStyle: .file))") + Text("预计展开 \(ByteCountFormatter.string(fromByteCount: Int64(result.expandedBytes), countStyle: .file))") + if let version = result.version { Text("备份版本 v\(version)") } + if let created = result.createdAt { + Text("备份时间 \(created.formatted(date: .abbreviated, time: .shortened))") + } + if !result.problems.isEmpty { + ForEach(result.problems, id: \.rawValue) { problem in + Label(problem.explanation, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundColor(.red) + } + } + Text("重要:CRC 和结构检查通过并不证明密码、数据库、存储密钥或身份恢复一定成功。") + .font(.caption).foregroundColor(.secondary) + Button("清除检查结果") { + generation = UUID() + self.result = nil + }.buttonStyle(.bordered) + } + .textSelection(.enabled) + } + } + .padding(16) + } + .navigationTitle("ZIP 备份安全体检") + .navigationBarTitleDisplayMode(.inline) + .fileImporter(isPresented: $presentingPicker, + allowedContentTypes: [.zip], allowsMultipleSelection: false) { pick in + guard case .success(let urls) = pick, let file = urls.first else { return } + let token = UUID() + generation = token + working = true + result = nil + let runDeep = deepCRC + Task { + let inspected = await Task.detached(priority: .userInitiated) { + VeilBackupArchiveInspector.inspect(file, verifyAllCRC: runDeep) + }.value + guard generation == token else { return } + result = inspected + working = false + } + } + .onDisappear { + // Outstanding work is read-only; discard its results if the page closes. + generation = UUID() + result = nil + working = false + } + } +} diff --git a/VeilLink/UI/VeilEmotionGardenView.swift b/VeilLink/UI/VeilEmotionGardenView.swift new file mode 100644 index 00000000..e46063e9 --- /dev/null +++ b/VeilLink/UI/VeilEmotionGardenView.swift @@ -0,0 +1,373 @@ +import SwiftUI +import Combine + +struct VeilEmotionGardenView: View { + @State private var tone: VeilEmotionTone = .calm + @State private var context: VeilEmotionContext = .morning + @State private var query = "" + @State private var selected: VeilEmotionRitual? + @State private var completedIDs = Set() + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var path: [VeilEmotionRitual] { + VeilEmotionGardenEngine.dailyPath(for: tone, context: context, date: Date()) + } + + private var displayedRituals: [VeilEmotionRitual] { + query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? VeilEmotionGardenEngine.rituals(for: tone, context: context) + : VeilEmotionGardenEngine.search(query) + } + + var body: some View { + VeilStableScrollView { + VStack(alignment: .leading, spacing: 16) { + hero + tonePicker + contextPicker + note + searchField + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("今日三步").font(.headline).foregroundColor(VeilTheme.text) + Spacer() + Text("1000 项 · 完全离线").font(.caption.bold()).foregroundColor(VeilTheme.mutedGold) + } + ForEach(path) { ritual in ritualCard(ritual) } + } + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(query.isEmpty ? "自己挑一个" : "全库搜索结果").font(.headline).foregroundColor(VeilTheme.text) + Spacer() + Text("\(displayedRituals.count)").font(.caption.monospacedDigit()).foregroundColor(VeilTheme.tertiaryText) + } + LazyVGrid(columns: [GridItem(.adaptive(minimum: 145), spacing: 10)], spacing: 10) { + ForEach(displayedRituals) { ritual in + Button { selected = ritual } label: { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: ritual.symbol).font(.title3).foregroundColor(VeilTheme.gold) + Text(ritual.kind.title).font(.subheadline.bold()).foregroundColor(VeilTheme.text) + Text("\(ritual.seconds) 秒").font(.caption2).foregroundColor(VeilTheme.secondaryText) + } + .frame(maxWidth: .infinity, minHeight: 92, alignment: .leading) + .padding(12) + .veilCompactToolSurface(cornerRadius: 15) + } + .buttonStyle(VeilPressStyle()) + } + } + } + safetyNote + } + } + .padding(.horizontal, 14) + .background(VeilAmbientBackground()) + .navigationTitle("情绪花园") + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $selected) { ritual in + VeilEmotionRitualSheet( + ritual: ritual, + completed: completedIDs.contains(ritual.id), + markCompleted: { completedIDs.insert(ritual.id) } + ) + } + .animation(reduceMotion ? nil : VeilMotionPolicy.animation(.transit, reduceMotionRequested: reduceMotion), value: tone) + .animation(reduceMotion ? nil : VeilMotionPolicy.animation(.transit, reduceMotionRequested: reduceMotion), value: context) + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 10) { + Text("EMOTION GARDEN") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .tracking(1.4) + .foregroundColor(VeilTheme.mutedGold) + Text("给现在的你,一点空间。") + .font(.system(size: 27, weight: .bold, design: .rounded)) + .foregroundColor(VeilTheme.text) + Text("1000 种状态 × 互动 × 场景组合。不是诊断,也没有打卡压力。") + .font(.caption) + .foregroundColor(VeilTheme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background( + LinearGradient( + colors: [VeilTheme.gold.opacity(0.20), VeilTheme.panelSoft.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + private var tonePicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text("你现在更接近哪一种?").font(.headline).foregroundColor(VeilTheme.text) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(VeilEmotionTone.allCases) { value in + Button { tone = value } label: { + Text(value.title) + .font(.caption.bold()) + .foregroundColor(tone == value ? VeilTheme.panel : VeilTheme.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(tone == value ? VeilTheme.gold : VeilTheme.panelSoft) + .clipShape(Capsule()) + } + .buttonStyle(VeilPressStyle()) + } + } + } + } + } + + private var note: some View { + Text(VeilEmotionGardenEngine.supportiveNote(for: tone)) + .font(.body) + .foregroundColor(VeilTheme.text) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .veilCompactToolSurface(cornerRadius: 16) + } + + private var searchField: some View { + HStack(spacing: 9) { + Image(systemName: "magnifyingglass").foregroundColor(VeilTheme.tertiaryText) + TextField("搜索 EV-编号、状态、场景或内容", text: $query) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill").foregroundColor(VeilTheme.tertiaryText) + } + .accessibilityLabel("清空搜索") + } + } + .padding(.horizontal, 13) + .frame(height: 44) + .veilCompactToolSurface(cornerRadius: 14) + } + + private var contextPicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text("你正在哪个场景?").font(.headline).foregroundColor(VeilTheme.text) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(VeilEmotionContext.allCases) { value in + Button { context = value } label: { + Text(value.title) + .font(.caption.bold()) + .foregroundColor(context == value ? VeilTheme.panel : VeilTheme.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(context == value ? VeilTheme.gold : VeilTheme.panelSoft) + .clipShape(Capsule()) + } + .buttonStyle(VeilPressStyle()) + } + } + } + } + } + + private func ritualCard(_ ritual: VeilEmotionRitual) -> some View { + Button { selected = ritual } label: { + HStack(spacing: 12) { + Image(systemName: completedIDs.contains(ritual.id) ? "checkmark.circle.fill" : ritual.symbol) + .font(.title3) + .foregroundColor(completedIDs.contains(ritual.id) ? .green : VeilTheme.gold) + .frame(width: 30) + VStack(alignment: .leading, spacing: 3) { + Text("\(ritual.context.title) · \(ritual.kind.title)").font(.subheadline.bold()).foregroundColor(VeilTheme.text) + Text(ritual.prompt).font(.caption).foregroundColor(VeilTheme.secondaryText).lineLimit(2) + } + Spacer(minLength: 0) + Text("\(ritual.seconds)s").font(.caption2.monospacedDigit()).foregroundColor(VeilTheme.tertiaryText) + } + .padding(13) + .veilCompactToolSurface(cornerRadius: 16) + } + .buttonStyle(VeilPressStyle()) + } + + private var safetyNote: some View { + Text("若你正感到可能伤害自己或他人的紧急危险,请优先联系当地急救服务、危机支持资源或可信任的人。") + .font(.caption2) + .foregroundColor(VeilTheme.tertiaryText) + .padding(.vertical, 8) + } +} + +private struct VeilEmotionRitualSheet: View { + let ritual: VeilEmotionRitual + let completed: Bool + let markCompleted: () -> Void + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var completedSteps = Set() + @StateObject private var clock: VeilEmotionRitualClock + + init(ritual: VeilEmotionRitual, completed: Bool, markCompleted: @escaping () -> Void) { + self.ritual = ritual + self.completed = completed + self.markCompleted = markCompleted + _clock = StateObject(wrappedValue: VeilEmotionRitualClock(duration: ritual.seconds)) + } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Image(systemName: ritual.symbol) + .font(.system(size: 40, weight: .medium)) + .foregroundColor(VeilTheme.gold) + Text(ritual.title).font(.title2.bold()).foregroundColor(VeilTheme.text) + Text(ritual.prompt).font(.body).foregroundColor(VeilTheme.secondaryText).fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(ritual.steps.enumerated()), id: \.offset) { index, step in + Button { + if completedSteps.contains(index) { completedSteps.remove(index) } + else { completedSteps.insert(index) } + } label: { + HStack(alignment: .top, spacing: 9) { + Image(systemName: completedSteps.contains(index) ? "checkmark.circle.fill" : "circle") + .foregroundColor(completedSteps.contains(index) ? .green : VeilTheme.tertiaryText) + Text(step).font(.subheadline).foregroundColor(VeilTheme.text).frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .veilCompactToolSurface(cornerRadius: 12) + } + .buttonStyle(.plain) + } + } + countdown + HStack(spacing: 10) { + Button { + clock.toggle() + } label: { + Label(clock.isRunning ? "暂停" : (clock.remainingSeconds == ritual.seconds ? "开始" : "继续"), systemImage: clock.isRunning ? "pause.fill" : "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + Button { + clock.reset() + } label: { + Label("重置", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + } + Text("建议用时 \(ritual.seconds) 秒。可以暂停、重置或随时跳过。") + .font(.caption).foregroundColor(VeilTheme.tertiaryText) + if completedSteps.count == ritual.steps.count || clock.remainingSeconds == 0 { + Text(ritual.completionNote) + .font(.subheadline) + .foregroundColor(VeilTheme.goldBright) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .veilCompactToolSurface(cornerRadius: 13) + } + Button { + markCompleted() + dismiss() + } label: { + Label(completed ? "已完成" : "完成这一小步", systemImage: completed ? "checkmark.circle.fill" : "checkmark") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .background(VeilTheme.gold.opacity(0.18)) + .foregroundColor(VeilTheme.goldBright) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(VeilPressStyle()) + } + .padding(22) + } + .background(VeilAmbientBackground()) + .navigationTitle("微小陪伴") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } } + .onDisappear { clock.pause() } + } + } + + private var countdown: some View { + let progress = ritual.seconds > 0 + ? Double(ritual.seconds - clock.remainingSeconds) / Double(ritual.seconds) + : 1 + return ZStack { + Circle().stroke(VeilTheme.panelSoft, lineWidth: 8) + Circle() + .trim(from: 0, to: max(0, min(1, progress))) + .stroke(VeilTheme.gold, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 3) { + Text("\(clock.remainingSeconds)").font(.system(size: 30, weight: .bold, design: .rounded)).monospacedDigit().foregroundColor(VeilTheme.text) + Text(clock.remainingSeconds == 0 ? "完成" : "秒").font(.caption).foregroundColor(VeilTheme.secondaryText) + } + } + .frame(width: 112, height: 112) + .frame(maxWidth: .infinity) + .animation(reduceMotion ? nil : .linear(duration: 0.25), value: clock.remainingSeconds) + .accessibilityElement(children: .ignore) + .accessibilityLabel(clock.remainingSeconds == 0 ? "计时完成" : "剩余 \(clock.remainingSeconds) 秒") + } +} + +private final class VeilEmotionRitualClock: ObservableObject { + @Published private(set) var remainingSeconds: Int + @Published private(set) var isRunning = false + + private let duration: Int + private var deadline: Date? + private var timer: Timer? + + init(duration: Int) { + self.duration = max(1, duration) + remainingSeconds = max(1, duration) + } + + func toggle() { + if isRunning { pause() } + else { start() } + } + + func pause() { + updateRemaining() + stopTimer() + } + + func reset() { + stopTimer() + remainingSeconds = duration + } + + private func start() { + if remainingSeconds == 0 { remainingSeconds = duration } + deadline = Date().addingTimeInterval(TimeInterval(remainingSeconds)) + isRunning = true + let next = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.tick() } + next.tolerance = 0.15 + RunLoop.main.add(next, forMode: .common) + timer = next + } + + private func tick() { + updateRemaining() + if remainingSeconds == 0 { stopTimer() } + } + + private func updateRemaining() { + guard let deadline else { return } + remainingSeconds = max(0, Int(ceil(deadline.timeIntervalSinceNow))) + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + deadline = nil + isRunning = false + } + + deinit { timer?.invalidate() } +} diff --git a/VeilLink/UI/VeilFeatureLibraryView.swift b/VeilLink/UI/VeilFeatureLibraryView.swift new file mode 100644 index 00000000..c6e40a4c --- /dev/null +++ b/VeilLink/UI/VeilFeatureLibraryView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct VeilFeatureLibraryView: View { + let registry: VeilFeatureRegistry + let profile: VeilFeatureRuntimeProfile + let passedGates: VeilFeatureGate + let onOpen: (String) -> Void + + @State private var query = "" + @State private var domain: VeilFeatureDomain? + @State private var offset = 0 + + private let pageSize = 30 + + private var page: VeilFeatureQueryPage { + registry.query(VeilFeatureQuery(text: query, domain: domain, offset: offset, limit: pageSize)) + } + + var body: some View { + VeilStableScrollView { + VStack(alignment: .leading, spacing: 14) { + header + search + domainFilter + resultSummary + + LazyVStack(spacing: 10) { + ForEach(page.items) { feature in + featureRow(feature) + } + } + + if page.totalCount == 0 { + Text("没有匹配的功能") + .font(.callout) + .foregroundColor(VeilTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } else { + pagination + } + } + } + .padding(.horizontal, 14) + .background(VeilAmbientBackground()) + .navigationTitle("功能库") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: query) { _ in offset = 0 } + .onChange(of: domain) { _ in offset = 0 } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 5) { + Text("FEATURE LIBRARY") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .tracking(1.3) + .foregroundColor(VeilTheme.mutedGold) + Text("一个入口,按需加载") + .font(.title2.bold()) + .foregroundColor(VeilTheme.text) + Text("这里只渲染当前 30 项;不可用功能会说明缺少的验证或权限。") + .font(.caption) + .foregroundColor(VeilTheme.secondaryText) + } + } + + private var search: some View { + HStack(spacing: 9) { + Image(systemName: "magnifyingglass").foregroundColor(VeilTheme.tertiaryText) + TextField("搜索名称、功能包、关键词或 ID", text: $query) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill").foregroundColor(VeilTheme.tertiaryText) + } + .accessibilityLabel("清空搜索") + } + } + .padding(.horizontal, 13) + .frame(height: 44) + .veilCompactToolSurface(cornerRadius: 14) + } + + private var domainFilter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + filterButton(title: "全部", value: nil) + ForEach(VeilFeatureDomain.allCases) { value in + filterButton(title: value.title, value: value) + } + } + } + } + + private func filterButton(title: String, value: VeilFeatureDomain?) -> some View { + Button { domain = value } label: { + Text(title) + .font(.caption.bold()) + .foregroundColor(domain == value ? VeilTheme.panel : VeilTheme.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(domain == value ? VeilTheme.gold : VeilTheme.panelSoft) + .clipShape(Capsule()) + } + .buttonStyle(VeilPressStyle()) + } + + private var resultSummary: some View { + HStack { + Text("共 \(page.totalCount) 项") + Spacer() + if page.totalCount > 0 { + Text("\(page.offset + 1)–\(min(page.offset + page.items.count, page.totalCount))") + } + } + .font(.caption.monospacedDigit()) + .foregroundColor(VeilTheme.tertiaryText) + } + + private func featureRow(_ feature: VeilFeatureDescriptor) -> some View { + let decision = registry.launchDecision(for: feature.id, profile: profile, passedGates: passedGates) + return Button { + if decision.canLaunch { onOpen(feature.id) } + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon(feature.domain)) + .font(.title3) + .foregroundColor(decision.canLaunch ? VeilTheme.gold : VeilTheme.tertiaryText) + .frame(width: 38, height: 38) + .background(VeilTheme.panelSoft) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(feature.title).font(.subheadline.bold()).foregroundColor(VeilTheme.text) + Spacer(minLength: 4) + Text(statusTitle(decision.state)) + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundColor(statusColor(decision.state)) + } + Text(feature.subtitle).font(.caption).foregroundColor(VeilTheme.secondaryText).lineLimit(2) + if let reason = decision.reason { + Text(reason).font(.caption2).foregroundColor(VeilTheme.tertiaryText).lineLimit(2) + } + } + Image(systemName: decision.canLaunch ? "chevron.right" : "lock.fill") + .font(.caption.bold()) + .foregroundColor(VeilTheme.tertiaryText) + .padding(.top, 3) + } + .padding(13) + .veilCompactToolSurface(cornerRadius: 15) + } + .buttonStyle(VeilPressStyle()) + .accessibilityHint(decision.canLaunch ? "打开功能" : (decision.reason ?? "当前不可用")) + } + + private var pagination: some View { + HStack(spacing: 10) { + Button("上一页") { offset = max(0, offset - pageSize) } + .disabled(offset == 0) + Spacer() + Button("下一页") { offset += pageSize } + .disabled(!page.hasMore) + } + .font(.subheadline.bold()) + .foregroundColor(VeilTheme.gold) + .padding(.vertical, 8) + } + + private func icon(_ domain: VeilFeatureDomain) -> String { + switch domain { + case .communication: return "bubble.left.and.bubble.right.fill" + case .productivity: return "checkmark.circle.fill" + case .diagnostics: return "waveform.path.ecg" + case .entertainment: return "sparkles" + case .emotion: return "heart.fill" + case .games: return "gamecontroller.fill" + case .utilities: return "wrench.and.screwdriver.fill" + case .experimental: return "flask.fill" + } + } + + private func statusTitle(_ state: VeilFeatureLaunchState) -> String { + switch state { + case .available: return "READY" + case .degraded: return "ADAPT" + case .blocked: return "GATED" + case .thermallyDeferred: return "COOL" + } + } + + private func statusColor(_ state: VeilFeatureLaunchState) -> Color { + switch state { + case .available: return .green + case .degraded: return .orange + case .blocked, .thermallyDeferred: return VeilTheme.tertiaryText + } + } +} diff --git a/VeilLink/UI/VeilMissionControlView.swift b/VeilLink/UI/VeilMissionControlView.swift new file mode 100644 index 00000000..a330f238 --- /dev/null +++ b/VeilLink/UI/VeilMissionControlView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +/// This screen is explicitly an offline, simulated analysis lab until real +/// telemetry adapters and device-specific privacy gates are separately approved. +struct VeilMissionControlView: View { + @State private var scenario: VeilMissionScenario = .healthy + @State private var onlyWarnings = false + private let sections = ["链路与路由", "传输与恢复", "可靠性与隐私", "设备与诊断"] + + private var findings: [VeilMissionFinding] { + let all = VeilMissionControlEngine.analyzeAll(scenario.snapshot) + return onlyWarnings ? all.filter { $0.severity != .normal } : all + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 9) { + Label("MISSION CONTROL / LOCAL LAB", systemImage: "waveform.path.ecg.rectangle") + .font(.caption.monospaced().weight(.bold)) + Text("20 项离线决策引擎").font(.title2.weight(.bold)) + Text("当前运行的是内置模拟样本,不会读取真实聊天、密钥或设备日志,也不会修改传输队列。") + .font(.footnote) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.secondary.opacity(0.10))) + + Picker("模拟场景", selection: $scenario) { + ForEach(VeilMissionScenario.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("missionControl.scenario") + + Toggle("只看异常与建议", isOn: $onlyWarnings) + .accessibilityIdentifier("missionControl.onlyWarnings") + + ForEach(sections, id: \.self) { group in + let entries = findings.filter { $0.feature.group == group } + if !entries.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text(group).font(.headline).padding(.bottom, 2) + ForEach(entries) { item in + DisclosureGroup { + Text(item.explanation) + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } label: { + HStack(alignment: .center, spacing: 10) { + Image(systemName: symbol(for: item.severity)) + .foregroundColor(tint(for: item.severity)) + VStack(alignment: .leading, spacing: 3) { + Text(item.feature.title).font(.subheadline.weight(.semibold)) + Text(item.headline).font(.caption).foregroundColor(.secondary) + } + Spacer(minLength: 8) + Text(item.value) + .font(.caption.monospacedDigit()) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.08))) + .accessibilityIdentifier("missionControl.\(item.feature.rawValue)") + } + } + } + } + Text("架构:零数据库迁移 · 零网络请求 · 不改变现有 Wire v4 / A9 / A10。真实诊断接入须经过独立安全与隐私验收。") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.bottom, 12) + } + .padding(16) + } + .navigationTitle("任务控制中心") + .navigationBarTitleDisplayMode(.inline) + } + + private func symbol(for severity: VeilMissionSeverity) -> String { + switch severity { + case .normal: return "checkmark.circle" + case .caution: return "exclamationmark.triangle" + case .blocked: return "xmark.octagon" + } + } + + private func tint(for severity: VeilMissionSeverity) -> Color { + switch severity { + case .normal: return .green + case .caution: return .orange + case .blocked: return .red + } + } +} diff --git a/VeilLink/UI/VeilOfflineHTMLReaderView.swift b/VeilLink/UI/VeilOfflineHTMLReaderView.swift new file mode 100644 index 00000000..2478cdf2 --- /dev/null +++ b/VeilLink/UI/VeilOfflineHTMLReaderView.swift @@ -0,0 +1,133 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// User-operated, offline-only HTML reading surface. +struct VeilOfflineHTMLReaderView: View { + @State private var showingImporter = false + @State private var document: VeilOfflineHTMLDocument? + @State private var query = "" + @State private var isLoading = false + @State private var errorDescription: String? + @State private var importGeneration = UUID() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 15) { + VStack(alignment: .leading, spacing: 6) { + Label("离线 HTML 资料阅读", systemImage: "doc.richtext") + .font(.title3.bold()) + Text("从“文件”导入已保存的 HTML。只抽取标题、正文和链接;不会执行脚本、访问网络或将文件写入聊天数据库。") + .font(.footnote).foregroundColor(.secondary) + } + Button { + showingImporter = true + } label: { + Label("选择 HTML 文件", systemImage: "folder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isLoading) + if isLoading { ProgressView("正在本机解析…") } + if let errorDescription { + Label(errorDescription, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundColor(.red) + } + if let document { + VStack(alignment: .leading, spacing: 10) { + Text(document.title).font(.headline) + .accessibilityIdentifier("offlineHTML.title") + Text("\(document.sections.count) 段 · \(document.links.count) 条链接") + .font(.caption).foregroundColor(.secondary) + if document.truncated { + Text("为兼容较旧的 iPhone,超出安全长度的内容已截断。") + .font(.caption).foregroundColor(.orange) + } + TextField("在当前资料内搜索", text: $query) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("offlineHTML.search") + let visible = document.matchingSections(query: query) + if visible.isEmpty { Text("没有匹配的正文。").foregroundColor(.secondary) } + ForEach(Array(visible.enumerated()), id: \.offset) { _, section in + Text(section) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .font(.body) + .padding(10) + .background(Color.secondary.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + if !query.isEmpty, visible.count == VeilHTMLImportPolicy.maximumSearchResults { + Text("搜索结果最多显示 100 段,请进一步缩小关键词。") + .font(.caption).foregroundColor(.secondary) + } + Button("复制当前可见正文") { + let text = visible.joined(separator: "\n\n") + VeilToolClipboard.copy(VeilHTMLImportPolicy.bounded(text)) + } + .buttonStyle(.bordered) + .disabled(visible.isEmpty) + + if !document.links.isEmpty { + Text("文档中的外部网址(只读)").font(.headline) + Text("不会自动访问这些链接。需要时可选中文本,手动复制地址。") + .font(.caption).foregroundColor(.secondary) + ForEach(document.links) { link in + VStack(alignment: .leading, spacing: 3) { + Text(link.label).font(.subheadline.weight(.semibold)) + Text(link.address) + .font(.caption.monospaced()) + .textSelection(.enabled) + .foregroundColor(.secondary) + } + } + } + Button("清除已导入的临时内容") { + importGeneration = UUID() + self.document = nil + self.query = "" + self.errorDescription = nil + }.buttonStyle(.borderless) + } + } + } + .padding(16) + } + .navigationTitle("离线资料阅读器") + .navigationBarTitleDisplayMode(.inline) + .fileImporter(isPresented: $showingImporter, + allowedContentTypes: [.html, UTType(filenameExtension: "htm") ?? .html], + allowsMultipleSelection: false) { result in + switch result { + case .failure: + errorDescription = "无法获取所选文件;请检查文件访问权限。" + case .success(let urls): + guard let url = urls.first else { return } + let token = UUID() + importGeneration = token + isLoading = true + document = nil + query = "" + errorDescription = nil + Task { + do { + let parsed = try await Task.detached(priority: .userInitiated) { + try VeilOfflineHTMLImporter.load(fileURL: url) + }.value + guard importGeneration == token else { return } + document = parsed + } catch { + guard importGeneration == token else { return } + errorDescription = (error as? LocalizedError)?.errorDescription ?? "HTML 导入失败。" + } + if importGeneration == token { isLoading = false } + } + } + } + .onDisappear { + importGeneration = UUID() + document = nil + query = "" + isLoading = false + } + } +} diff --git a/VeilLink/UI/VeilOfflineQRReaderView.swift b/VeilLink/UI/VeilOfflineQRReaderView.swift new file mode 100644 index 00000000..c05655c1 --- /dev/null +++ b/VeilLink/UI/VeilOfflineQRReaderView.swift @@ -0,0 +1,111 @@ +import CodeScanner +import SwiftUI + +/// Opt-in, device-local QR reader. No automatic URL loading or network access. +struct VeilOfflineQRReaderView: View { + @State private var showsCamera = false + @State private var scanResolved = false + @State private var capturedText = "" + @State private var failureText: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Label("本地二维码阅读器", systemImage: "qrcode.viewfinder") + .font(.headline) + Text("使用后置摄像头读取短文本。二维码不会自动执行任何操作,也不会自动打开网址。") + .font(.caption) + .foregroundColor(.secondary) + Button { + scanResolved = false + failureText = nil + showsCamera = true + } label: { + Label("打开摄像头扫描", systemImage: "camera.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + if let failureText { + Label(failureText, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundColor(.red) + } + + if !capturedText.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("扫描内容").font(.subheadline.weight(.semibold)) + Text(capturedText) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + Button { + VeilToolClipboard.copy(capturedText) + } label: { + Label("主动复制到剪贴板", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + Button("清除本次内容") { + capturedText = "" + } + .buttonStyle(.borderless) + } + } + + Text("隐私:扫描文本只保存在当前界面的内存中;不写入聊天数据库,不上传,不记录原始图像。复制是用户主动操作,系统剪贴板由 iOS 管理。单次内容最多 4 KiB。") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(16) + } + .navigationTitle("二维码阅读器") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showsCamera, onDismiss: { scanResolved = true }) { + NavigationView { + CodeScannerView( + codeTypes: [.qr], + scanMode: .once, + showViewfinder: true, + requiresPhotoOutput: false, + shouldVibrateOnSuccess: false + ) { response in + switch response { + case .success(let scanned): + let text = scanned.string + DispatchQueue.main.async { + guard showsCamera, !scanResolved else { return } + scanResolved = true + showsCamera = false + guard !text.isEmpty, text.utf8.count <= 4_096 else { + failureText = "二维码内容为空或超过 4 KiB,已拒绝读取。" + return + } + capturedText = text + failureText = nil + } + case .failure(let error): + let description = error.localizedDescription + DispatchQueue.main.async { + guard showsCamera, !scanResolved else { return } + scanResolved = true + showsCamera = false + failureText = "扫码失败:\(description)" + } + } + } + .navigationTitle("扫描二维码") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("取消") { + scanResolved = true + showsCamera = false + } + } + } + } + } + } +} diff --git a/VeilLink/UI/VeilProductivityLabView.swift b/VeilLink/UI/VeilProductivityLabView.swift new file mode 100644 index 00000000..d3aecf24 --- /dev/null +++ b/VeilLink/UI/VeilProductivityLabView.swift @@ -0,0 +1,321 @@ +import SwiftUI +import UIKit + +struct VeilProductivityLabView: View { + var body: some View { + VeilStableScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("PRODUCTIVITY LAB") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .tracking(1.2) + .foregroundColor(VeilTheme.mutedGold) + Text("协作效率实验室") + .font(.title2.bold()) + .foregroundColor(VeilTheme.text) + Text("所有计算均在本机完成;不会自动发送、导入或修改聊天。") + .font(.caption) + .foregroundColor(VeilTheme.secondaryText) + } + labLink("变量消息模板", detail: "复用常用消息,并在发送前检查漏填项", icon: "text.badge.checkmark", destination: VeilTemplateWorkbenchView()) + labLink("文本差异", detail: "逐行比较两个版本,清晰标出新增与删除", icon: "arrow.left.arrow.right", destination: VeilTextDiffView()) + labLink("三方合并", detail: "基线、本机、对端的保守合并与冲突提示", icon: "arrow.triangle.merge", destination: VeilThreeWayMergeView()) + labLink("跨时区规划", detail: "按参与者工作时间推荐共同可用时段", icon: "globe", destination: VeilTimeZonePlannerView()) + labLink("导入预检", detail: "导入前检查文件名、重复项、类型和总大小", icon: "tray.and.arrow.down.fill", destination: VeilImportPreflightView()) + } + } + .padding(.horizontal, 14) + .background(VeilAmbientBackground()) + .navigationTitle("效率实验室") + .navigationBarTitleDisplayMode(.inline) + } + + private func labLink(_ title: String, detail: String, icon: String, destination: Destination) -> some View { + NavigationLink(destination: destination) { + HStack(spacing: 13) { + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(VeilTheme.gold) + .frame(width: 42, height: 42) + .background(VeilTheme.gold.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline).foregroundColor(VeilTheme.text) + Text(detail).font(.caption).foregroundColor(VeilTheme.secondaryText).fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right").font(.caption.bold()).foregroundColor(VeilTheme.tertiaryText) + } + .padding(14) + .veilCompactToolSurface(cornerRadius: 16) + } + .buttonStyle(VeilPressStyle()) + } +} + +private struct VeilTemplateWorkbenchView: View { + @State private var template = "你好 {{称呼}},文件 {{文件名}} 已准备好。" + @State private var values: [String: String] = [:] + @State private var result = "" + @State private var error = "" + + private var variables: [String] { + (try? VeilProductivityEngine.analyzeTemplate(template).variables) ?? [] + } + + var body: some View { + VeilLabForm(title: "变量消息模板", note: "使用 {{变量名}}。生成结果只会留在本页,不会自动发送。") { + VeilLabEditor(title: "模板", text: $template, minimumHeight: 110) + ForEach(variables, id: \.self) { variable in + VStack(alignment: .leading, spacing: 5) { + Text(variable).font(.caption.bold()).foregroundColor(VeilTheme.secondaryText) + TextField("填写 \(variable)", text: Binding( + get: { values[variable, default: ""] }, + set: { values[variable] = $0 } + )) + .textFieldStyle(.roundedBorder) + } + } + VeilLabAction(title: "生成预览", icon: "wand.and.stars") { render() } + VeilLabResult(text: result, error: error) + } + .navigationTitle("变量模板") + } + + private func render() { + do { result = try VeilProductivityEngine.renderTemplate(template, values: values); error = "" } + catch { result = ""; self.error = error.localizedDescription } + } +} + +private struct VeilTextDiffView: View { + @State private var old = "第一行\n旧内容" + @State private var new = "第一行\n新内容" + @State private var diff: [VeilDiffLine] = [] + @State private var error = "" + + var body: some View { + VeilLabForm(title: "文本差异", note: "最多每侧 400 行 / 64 KB,计算有硬上限,不会拖垮老设备。") { + VeilLabEditor(title: "旧版本", text: $old, minimumHeight: 90) + VeilLabEditor(title: "新版本", text: $new, minimumHeight: 90) + VeilLabAction(title: "比较", icon: "arrow.left.arrow.right") { compare() } + if !error.isEmpty { Text(error).font(.caption).foregroundColor(.red) } + if !diff.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(diff.enumerated()), id: \.offset) { _, line in + HStack(alignment: .top, spacing: 7) { + Text(marker(line.kind)).frame(width: 12) + Text(line.text.isEmpty ? " " : line.text).textSelection(.enabled) + } + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(color(line.kind)) + } + } + .padding(12) + .veilCompactToolSurface(cornerRadius: 13) + } + } + .navigationTitle("文本差异") + } + + private func compare() { + do { diff = try VeilProductivityEngine.diff(old: old, new: new); error = "" } + catch { diff = []; self.error = error.localizedDescription } + } + private func marker(_ kind: VeilDiffKind) -> String { kind == .added ? "+" : (kind == .removed ? "−" : " ") } + private func color(_ kind: VeilDiffKind) -> Color { kind == .added ? .green : (kind == .removed ? .red : VeilTheme.secondaryText) } +} + +private struct VeilThreeWayMergeView: View { + @State private var base = "共同基线" + @State private var local = "共同基线" + @State private var remote = "对端修改" + @State private var result = "" + @State private var error = "" + + var body: some View { + VeilLabForm(title: "三方合并", note: "仅在一侧改动时自动合并;两侧均改变时保留双方并明确标记冲突。") { + VeilLabEditor(title: "基线", text: $base, minimumHeight: 70) + VeilLabEditor(title: "本机", text: $local, minimumHeight: 70) + VeilLabEditor(title: "对端", text: $remote, minimumHeight: 70) + VeilLabAction(title: "安全合并", icon: "arrow.triangle.merge") { + do { let merged = try VeilProductivityEngine.threeWayMerge(base: base, local: local, remote: remote); result = merged.text; error = merged.hasConflict ? "检测到双边冲突,请人工选择后再发送。" : "" } + catch { result = ""; self.error = error.localizedDescription } + } + VeilLabResult(text: result, error: error) + } + .navigationTitle("三方合并") + } +} + +private struct VeilTimeZonePlannerView: View { + @State private var zoneText = "上海|Asia/Shanghai|9|18\n纽约|America/New_York|9|18" + @State private var start = Date() + @State private var slots: [VeilMeetingSlot] = [] + @State private var error = "" + + var body: some View { + VeilLabForm(title: "跨时区规划", note: "每行格式:名称|IANA 时区|开始小时|结束小时。自动按夏令时换算。") { + VeilLabEditor(title: "参与者", text: $zoneText, minimumHeight: 110) + DatePicker("从此时间开始", selection: $start) + .foregroundColor(VeilTheme.text) + VeilLabAction(title: "推荐时段", icon: "clock.badge.checkmark") { calculate() } + if !error.isEmpty { Text(error).font(.caption).foregroundColor(.red) } + ForEach(Array(slots.enumerated()), id: \.offset) { _, slot in + VStack(alignment: .leading, spacing: 5) { + Text("舒适度 \(slot.comfortableParticipantCount)/\(slot.totalParticipantCount)").font(.subheadline.bold()).foregroundColor(VeilTheme.gold) + ForEach(slot.localTimes, id: \.self) { Text($0).font(.caption).foregroundColor(VeilTheme.secondaryText) } + } + .padding(12) + .veilCompactToolSurface(cornerRadius: 13) + } + } + .navigationTitle("跨时区规划") + } + + private func calculate() { + let rows = zoneText.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let participants = rows.compactMap { line -> VeilTimeZoneParticipant? in + let parts = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 4, let begin = Int(parts[2]), let end = Int(parts[3]), (0...23).contains(begin), (1...24).contains(end), begin < end else { return nil } + return VeilTimeZoneParticipant(label: parts[0], timeZoneIdentifier: parts[1], preferredStartHour: begin, preferredEndHour: end) + } + guard participants.count == rows.count else { + slots = [] + error = "存在格式或工作时间无效的参与者行" + return + } + do { slots = try VeilProductivityEngine.meetingSlots(from: start, participants: participants); error = "" } + catch { slots = []; self.error = error.localizedDescription } + } +} + +private struct VeilImportPreflightView: View { + @State private var itemText = "photo.jpg|2048000\nnotes.txt|1024" + @State private var extensions = "jpg,png,txt,pdf" + @State private var result: VeilImportPreflight? + @State private var error = "" + + var body: some View { + VeilLabForm(title: "导入预检", note: "每行格式:文件名|字节数。这里只做风险检查,不会读取或导入文件。") { + VeilLabEditor(title: "清单", text: $itemText, minimumHeight: 110) + VStack(alignment: .leading, spacing: 5) { + Text("允许扩展名(逗号分隔)").font(.caption.bold()).foregroundColor(VeilTheme.secondaryText) + TextField("留空表示不限", text: $extensions).textFieldStyle(.roundedBorder) + } + VeilLabAction(title: "执行预检", icon: "checkmark.shield") { inspect() } + if !error.isEmpty { Text(error).font(.caption).foregroundColor(.red) } + if let result { + VStack(alignment: .leading, spacing: 7) { + Label(result.canImport ? "预检通过" : "需要处理", systemImage: result.canImport ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.headline).foregroundColor(result.canImport ? .green : .orange) + Text("\(result.itemCount) 项 · \(ByteCountFormatter.string(fromByteCount: result.totalBytes, countStyle: .file))") + .font(.caption).foregroundColor(VeilTheme.secondaryText) + ForEach(Array(result.issues.enumerated()), id: \.offset) { _, issue in + Text("• \(issue.itemName.map { "\($0):" } ?? "")\(issue.message)") + .font(.caption).foregroundColor(issue.severity == .blocking ? .red : .orange) + } + } + .padding(12) + .veilCompactToolSurface(cornerRadius: 13) + } + } + .navigationTitle("导入预检") + } + + private func inspect() { + let rows = itemText.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let items = rows.compactMap { line -> VeilImportItem? in + let parts = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 2, let bytes = Int64(parts[1]) else { return nil } + return VeilImportItem(name: parts[0], byteCount: bytes) + } + guard items.count == rows.count else { + result = nil + error = "存在格式或字节数无效的清单行" + return + } + let allowed = Set(extensions.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }.filter { !$0.isEmpty }) + do { result = try VeilProductivityEngine.importPreflight(items: items, allowedExtensions: allowed); error = "" } + catch { result = nil; self.error = error.localizedDescription } + } +} + +private struct VeilLabForm: View { + let title: String + let note: String + @ViewBuilder let content: Content + + init(title: String, note: String, @ViewBuilder content: () -> Content) { + self.title = title + self.note = note + self.content = content() + } + + var body: some View { + VeilStableScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(title).font(.title2.bold()).foregroundColor(VeilTheme.text) + Text(note).font(.caption).foregroundColor(VeilTheme.secondaryText).fixedSize(horizontal: false, vertical: true) + content + } + } + .padding(.horizontal, 14) + .background(VeilAmbientBackground()) + } +} + +private struct VeilLabEditor: View { + let title: String + @Binding var text: String + let minimumHeight: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title).font(.caption.bold()).foregroundColor(VeilTheme.secondaryText) + TextEditor(text: $text) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(VeilTheme.text) + .frame(minHeight: minimumHeight) + .padding(8) + .veilCompactToolSurface(cornerRadius: 12) + } + } +} + +private struct VeilLabAction: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: icon) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(VeilTheme.gold.opacity(0.16)) + .foregroundColor(VeilTheme.goldBright) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + .buttonStyle(VeilPressStyle()) + } +} + +private struct VeilLabResult: View { + let text: String + let error: String + + var body: some View { + if !error.isEmpty { Text(error).font(.caption).foregroundColor(.red) } + if !text.isEmpty { + VStack(alignment: .trailing, spacing: 8) { + Text(text).font(.system(size: 13, design: .monospaced)).foregroundColor(VeilTheme.text).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + Button("复制") { UIPasteboard.general.string = text } + .font(.caption.bold()).foregroundColor(VeilTheme.gold) + } + .padding(12) + .veilCompactToolSurface(cornerRadius: 13) + } + } +} diff --git a/VeilLink/UI/VeilRareLabView.swift b/VeilLink/UI/VeilRareLabView.swift new file mode 100644 index 00000000..4aca82ae --- /dev/null +++ b/VeilLink/UI/VeilRareLabView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +/// iOS 15-compatible standalone lab: user-entered, anonymized JSON only. +/// No diagnostic privilege or network operation is executed by this view. +struct VeilRareLabView: View { + @State private var search = "" + @State private var selection = 1 + @State private var input = VeilRareLab.sample(for: 1) + @State private var result = "" + @State private var errors = "" + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var matches: [VeilRareTool] { + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + if query.isEmpty { return VeilRareLab.catalog } + return VeilRareLab.catalog.filter { $0.title.localizedCaseInsensitiveContains(query) + || $0.category.localizedCaseInsensitiveContains(query) + || $0.detail.localizedCaseInsensitiveContains(query) + || "\($0.id)" == query } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 13) { + VStack(alignment: .leading, spacing: 5) { + Text("RARE LAB · OFFLINE FORENSICS") + .font(.caption.monospaced().weight(.bold)) + Text("40 项稀有运算器").font(.title2.bold()) + Text("本地手动输入脱敏 JSON,立即分析。不会读取密钥、扫描其他设备或变更实际通信行为。") + .font(.footnote).foregroundColor(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + TextField("搜索 40 项能力", text: $search) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("rareLab.search") + + ForEach(["无线与 Mesh", "传输与恢复", "身份与隐私", "触控与取证"], id: \.self) { category in + let group = matches.filter { $0.category == category } + if !group.isEmpty { + Text(category).font(.headline).padding(.top, 7) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 128), spacing: 8)], spacing: 8) { + ForEach(group) { tool in + Button { + selection = tool.id + input = VeilRareLab.sample(for: tool.id) + result = ""; errors = "" + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(String(format: "%02d · %@", tool.id, tool.title)) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + Text(tool.detail).font(.caption2) + .foregroundColor(.secondary).lineLimit(2) + } + .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) + .padding(9) + .background(RoundedRectangle(cornerRadius: 11) + .fill(selection == tool.id ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08))) + } + .buttonStyle(.plain) + .accessibilityIdentifier("rareLab.tool.\(tool.id)") + } + } + } + } + Divider().padding(.vertical, 4) + if let active = VeilRareLab.catalog.first(where: { $0.id == selection }) { + Text("#\(selection) · \(active.title)").font(.headline) + Text(active.detail).font(.footnote).foregroundColor(.secondary) + } + Text("输入区仅接受匿名示例。请勿输入实际聊天内容、身份密钥、真实二维码或私人信息。") + .font(.caption2).foregroundColor(.orange) + TextEditor(text: $input) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 125) + .padding(5) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.35))) + .accessibilityIdentifier("rareLab.input") + HStack { + Button("运行本地分析") { + do { result = try VeilRareLab.run(id: selection, json: input); errors = "" } + catch { errors = error.localizedDescription; result = "" } + }.buttonStyle(.borderedProminent) + Button("恢复示例") { input = VeilRareLab.sample(for: selection); result = ""; errors = "" } + .buttonStyle(.bordered) + } + if !errors.isEmpty { Text(errors).font(.footnote).foregroundColor(.red) } + if !result.isEmpty { + Text(result).font(.subheadline.monospaced()) + .textSelection(.enabled).padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08))) + .accessibilityIdentifier("rareLab.output") + } + Text("计算结果是有限样本的离线启发式分析,不等于真实 BLE 链路、身份校验或触控失效的最终判定。") + .font(.caption2).foregroundColor(.secondary).padding(.bottom, 20) + }.padding(16) + } + .navigationTitle("稀有能力实验室") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/VeilLink/UI/VeilReleaseActivationView.swift b/VeilLink/UI/VeilReleaseActivationView.swift index 9b77dd7f..637a9353 100644 --- a/VeilLink/UI/VeilReleaseActivationView.swift +++ b/VeilLink/UI/VeilReleaseActivationView.swift @@ -121,7 +121,7 @@ struct VeilReleaseActivationView: View { if VeilReleaseActivationInteractionPolicy.allowsOnboardingSwipe(on: page) { pageSurface(proxy: proxy) .contentShape(Rectangle()) - .gesture(swipeGesture) + .simultaneousGesture(swipeGesture) } else { pageSurface(proxy: proxy) } @@ -132,6 +132,7 @@ struct VeilReleaseActivationView: View { pageContent(proxy: proxy) .id(page) .opacity(contentVisible ? 1 : 0) + .allowsHitTesting(contentVisible) .scaleEffect(contentVisible || motionReduced ? 1 : 0.975) .offset(x: motionReduced ? 0 : dragX * 0.12, y: contentVisible || motionReduced ? 0 : 12) } @@ -359,10 +360,18 @@ struct VeilReleaseActivationView: View { } private var swipeGesture: some Gesture { - DragGesture(minimumDistance: 12) - .updating($dragX) { value, state, _ in state = value.translation.width } + DragGesture(minimumDistance: 20) + .updating($dragX) { value, state, _ in + let x = abs(value.translation.width) + let y = abs(value.translation.height) + // Only animate horizontal paging. Vertical pans belong to the ScrollView. + state = x > y * 1.35 ? value.translation.width : 0 + } .onEnded { value in - guard abs(value.predictedEndTranslation.width) > 72 else { return } + let x = abs(value.translation.width) + let y = abs(value.translation.height) + guard x > y * 1.35, + abs(value.predictedEndTranslation.width) > 72 else { return } if value.predictedEndTranslation.width < 0, page < 3 { move(to: page + 1) } else if value.predictedEndTranslation.width > 0, page > 0 { diff --git a/VeilLink/UI/VeilV27FeatureHubView.swift b/VeilLink/UI/VeilV27FeatureHubView.swift new file mode 100644 index 00000000..76a4f900 --- /dev/null +++ b/VeilLink/UI/VeilV27FeatureHubView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct VeilV27FeatureHubView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var activeFeatureID: String? + + private static let registry: VeilFeatureRegistry = { + let items: [VeilFeatureDescriptor] = [ + item("v27.mission-control", "mission-control-r1", "任务控制台", "20 项离线通信与会话诊断", .diagnostics, .standard, ["诊断", "通信", "安全"]), + item("v27.rare-lab", "rare-lab-r1", "RareLab", "40 项离线稀有分析运算器", .experimental, .standard, ["分析", "取证", "实验"]), + item("v27.productivity", "feature-century-f1", "效率实验室", "模板、差异、合并与时区规划", .productivity, .featherweight, ["文本", "合并", "时区"]), + item("v27.emotion-garden", "emotion-value-v1", "情绪花园", "1,000 条可交互微仪式", .emotion, .standard, ["陪伴", "情绪", "计时"]), + item("v27.aether-lab", "aether-lab-r1", "AetherLab", "100 项本地算法实验", .experimental, .standard, ["算法", "信号", "几何"]), + item("v27.story-lab", "entertainment-e01", "故事实验室", "分支叙事与多结局体验", .entertainment, .standard, ["剧情", "选择", "结局"]), + item("v27.rhythm", "entertainment-r7", "实时节奏远征", "SpriteKit 实时判定与原生渲染", .games, .renderHeavy, ["节奏", "实时", "SpriteKit"]), + item("v27.lattice", "lattice-clash-v1", "晶格冲突", "确定性单机策略对战", .games, .standard, ["策略", "单机", "棋盘"]), + item("v27.qr-reader", "practical-qr-r1", "离线二维码", "相机扫描与本地文本交换", .communication, .featherweight, ["二维码", "相机", "配对"]), + item("v27.html-reader", "practical-html-r2", "离线 HTML 阅读器", "从文件提取正文并搜索", .utilities, .standard, ["HTML", "文件", "阅读"]), + item("v27.backup-preflight", "backup-preflight-r3", "备份安全体检", "恢复前检查 ZIP 结构与完整性", .utilities, .standard, ["备份", "ZIP", "CRC"]) + ] + return try! VeilFeatureRegistry(features: items) + }() + + var body: some View { + ZStack { + VeilFeatureLibraryView( + registry: Self.registry, + profile: runtimeProfile, + passedGates: [], + onOpen: { activeFeatureID = $0 } + ) + + NavigationLink( + destination: destination, + isActive: Binding( + get: { activeFeatureID != nil }, + set: { if !$0 { activeFeatureID = nil } } + ), + label: { EmptyView() } + ) + .hidden() + } + } + + private var destination: AnyView { + switch activeFeatureID { + case "v27.mission-control": return AnyView(VeilMissionControlView()) + case "v27.rare-lab": return AnyView(VeilRareLabView()) + case "v27.productivity": return AnyView(VeilProductivityLabView()) + case "v27.emotion-garden": return AnyView(VeilEmotionGardenView()) + case "v27.aether-lab": return AnyView(VeilAetherLabView()) + case "v27.story-lab": return AnyView(VeilStoryLabView()) + case "v27.rhythm": return AnyView(VeilRhythmR6NativeGameView()) + case "v27.lattice": return AnyView(LatticeClashLabView()) + case "v27.qr-reader": return AnyView(VeilOfflineQRReaderView()) + case "v27.html-reader": return AnyView(VeilOfflineHTMLReaderView()) + case "v27.backup-preflight": return AnyView(VeilBackupPreflightView()) + default: return AnyView(EmptyView()) + } + } + + private var runtimeProfile: VeilFeatureRuntimeProfile { + let thermal: VeilFeatureRuntimeProfile.ThermalPressure + switch ProcessInfo.processInfo.thermalState { + case .nominal: thermal = .nominal + case .fair: thermal = .fair + case .serious: thermal = .serious + case .critical: thermal = .critical + @unknown default: thermal = .serious + } + return VeilFeatureRuntimeProfile( + isLegacyDevice: VeilRenderProfile.usesLegacyCompositorPath, + lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled, + reduceMotion: reduceMotion, + godModeHighQuality: VeilPerformanceOverrides.forceFullVisualEffects, + thermalPressure: thermal + ) + } + + private static func item( + _ id: String, + _ packID: String, + _ title: String, + _ subtitle: String, + _ domain: VeilFeatureDomain, + _ resourceClass: VeilFeatureResourceClass, + _ keywords: [String] + ) -> VeilFeatureDescriptor { + VeilFeatureDescriptor( + id: id, + packID: packID, + title: title, + subtitle: subtitle, + domain: domain, + keywords: keywords, + resourceClass: resourceClass, + requiredGates: [], + conflictGroup: nil, + isExperimental: domain == .experimental + ) + } +} diff --git a/VeilLinkTests/LatticeClashGameTests.swift b/VeilLinkTests/LatticeClashGameTests.swift new file mode 100644 index 00000000..2e5dfc7a --- /dev/null +++ b/VeilLinkTests/LatticeClashGameTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import VeilLink + +final class LatticeClashGameTests: XCTestCase { + func testInitialAnchorsAndLegalMoves() { + let state = LatticeClashState() + + XCTAssertEqual(state.count(for: .host), 2) + XCTAssertEqual(state.count(for: .guest), 2) + XCTAssertEqual(state.currentPlayer, .host) + XCTAssertFalse(state.legalMoves(for: .host).isEmpty) + XCTAssertFalse(state.legalMoves(for: .guest).isEmpty) + } + + func testRemotePlacementIsRejectedWithoutMutation() { + var state = LatticeClashState() + let original = state + let center = LatticeClashState.index(row: 3, column: 3) + + XCTAssertFalse( + state.apply( + move: LatticeClashMove(index: center), + actor: .host + ) + ) + XCTAssertEqual(state, original) + } + + func testThreeSideSupportCapturesEnemyNode() { + var board = Array( + repeating: LatticeClashCell.empty, + count: LatticeClashState.cellCount + ) + board[17] = .host + board[23] = .host + board[25] = .host + board[24] = .guest + + var state = LatticeClashState( + board: board, + currentPlayer: .host + ) + + XCTAssertTrue( + state.apply( + move: LatticeClashMove(index: 31), + actor: .host + ) + ) + XCTAssertEqual(state.cell(at: 24), .host) + XCTAssertEqual(state.lastTurn?.captured, [24]) + } + + func testIdenticalMoveSequenceProducesIdenticalState() { + var first = LatticeClashState() + var second = LatticeClashState() + + for _ in 0..<12 { + guard !first.isFinished else { break } + let actor = first.currentPlayer + let move = first.legalMoves(for: actor).first + + XCTAssertEqual(actor, second.currentPlayer) + XCTAssertEqual(move, second.legalMoves(for: actor).first) + + guard let move else { break } + XCTAssertTrue(first.apply(move: move, actor: actor)) + XCTAssertTrue(second.apply(move: move, actor: actor)) + XCTAssertEqual(first, second) + } + } + + func testBotChoiceIsDeterministicAndRuleValidated() throws { + let state = LatticeClashState() + + let first = try XCTUnwrap( + LatticeClashBot.chooseMove(in: state, actor: .host) + ) + let second = try XCTUnwrap( + LatticeClashBot.chooseMove(in: state, actor: .host) + ) + + XCTAssertEqual(first, second) + + var probe = state + XCTAssertTrue(probe.apply(move: first, actor: .host)) + } + + func testMajorityControlEndsGame() { + var board = Array( + repeating: LatticeClashCell.empty, + count: LatticeClashState.cellCount + ) + for index in 0..<24 { + board[index] = .host + } + board[48] = .guest + + var state = LatticeClashState( + board: board, + currentPlayer: .host + ) + + XCTAssertTrue( + state.apply( + move: LatticeClashMove(index: 24), + actor: .host + ) + ) + XCTAssertEqual(state.count(for: .host), 25) + XCTAssertEqual(state.winner, .host) + XCTAssertTrue(state.isFinished) + } + + func testWrongActorIsRejected() throws { + var state = LatticeClashState() + let original = state + let guestMove = try XCTUnwrap( + state.legalMoves(for: .guest).first + ) + + XCTAssertFalse(state.apply(move: guestMove, actor: .guest)) + XCTAssertEqual(state, original) + } +} diff --git a/VeilLinkTests/VeilAetherLabEngineTests.swift b/VeilLinkTests/VeilAetherLabEngineTests.swift new file mode 100644 index 00000000..6d0963bf --- /dev/null +++ b/VeilLinkTests/VeilAetherLabEngineTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import VeilLink + +final class VeilAetherLabEngineTests: XCTestCase { + func testUniqueHundredFeaturesAndGroups() throws { + let all=VeilAetherEngine.catalog + XCTAssertEqual(all.count, 100) + XCTAssertEqual(Set(all.map(\.number)).count,100) + XCTAssertEqual(Set(all.map(\.domain)).count,10) + XCTAssertEqual(all.first?.number,201) + XCTAssertEqual(all.last?.number,300) + } + func testAllOneHundredExecutableAndDeterministic() throws { + for feature in VeilAetherEngine.catalog { + let first=try VeilAetherEngine.run(feature.number,json:VeilAetherEngine.exampleJSON) + let second=try VeilAetherEngine.run(feature.number,json:VeilAetherEngine.exampleJSON) + XCTAssertEqual(first,second,"feature \(feature.number) nondeterministic") + XCTAssertFalse(first.output.isEmpty,"feature \(feature.number) empty") + } + } + func testInputSizeAndMalformedData() throws { + XCTAssertThrowsError(try VeilAetherEngine.run(201,json:"not JSON")) + XCTAssertThrowsError(try VeilAetherEngine.run(201,json:"{\"foo\":\""+String(repeating:"a",count:32769)+"\"}")) + XCTAssertThrowsError(try VeilAetherEngine.run(301,json:"{}")) + XCTAssertThrowsError(try VeilAetherEngine.run(201,json:"[]")) + } + func testGraphAndShortestPaths() throws { + let sample="{\"edges\":[[0,1],[1,2],[2,3],[3,4]]}" + XCTAssertTrue(try VeilAetherEngine.run(202,json:sample).output.contains("12")) + XCTAssertTrue(try VeilAetherEngine.run(203,json:sample).output.contains("4")) + XCTAssertTrue(try VeilAetherEngine.run(206,json:sample).output.contains("1")) + } + func testSignalMedianAndMAD() throws { + let s="{\"numbers\":[1,1,1,1,100]}" + XCTAssertTrue(try VeilAetherEngine.run(212,json:s).output.contains("1.0000")) + XCTAssertTrue(try VeilAetherEngine.run(216,json:s).output.contains("1")) + } + func testClockAndQuorum() throws { + let s="{\"numbers\":[1,2,3,4,5,6],\"flags\":[true,true,false]}" + XCTAssertTrue(try VeilAetherEngine.run(225,json:s).output.contains("满足")) + XCTAssertTrue(try VeilAetherEngine.run(223,json:s).output.contains("A 先于 B")) + } + func testCRCAndPrivacy() throws { + let s="{\"text\":\"123456789\"}" + XCTAssertTrue(try VeilAetherEngine.run(237,json:s).output.lowercased().contains("cbf43926")) + } + func testAreaAndConvexHull() throws { + let s="{\"points\":[[0,0],[4,0],[4,3],[0,3]]}" + XCTAssertTrue(try VeilAetherEngine.run(271,json:s).output.contains("12.0000")) + XCTAssertTrue(try VeilAetherEngine.run(273,json:s).output.contains("4")) + } + func testKnowledgeAndEditDistance() throws { + let s="{\"strings\":[\"kitten\",\"sitting\"],\"text\":\"cat cat dog\"}" + XCTAssertTrue(try VeilAetherEngine.run(264,json:s).output.contains("3")) + XCTAssertTrue(try VeilAetherEngine.run(262,json:s).output.contains("2")) + } + func testGameAndReliableGate() throws { + let s="{\"grid\":[[0,0],[0,0]],\"numbers\":[1,2,3,4]}" + XCTAssertTrue(try VeilAetherEngine.run(251,json:s).output.contains("2")) + XCTAssertTrue(try VeilAetherEngine.run(252,json:s).output.contains("2")) + XCTAssertTrue(try VeilAetherEngine.run(281,json:s).output.contains("P95")) + } + func testEmptyDataDoesNotCrashAcrossAllFeatures() throws { + for feature in VeilAetherEngine.catalog { + let report=try VeilAetherEngine.run(feature.number,json:"{}") + XCTAssertFalse(report.output.isEmpty,"failed \(feature.number)") + } + } + func testExtremeNumericAndMalformedShapeSafety() throws { + let cases = [ + "{\"numbers\":[1e100,-1e100,0,0],\"grid\":[[1,0],[]]}", + "{\"numbers\":[-100000000,0,100000000],\"edges\":[[-1,3],[200,1],[3,3]]}", + "{\"points\":[[100000,100000],[-100000,-100000]],\"text\":\"😀 é 漢字\"}", + "{\"numbers\":[],\"strings\":[],\"edges\":[],\"grid\":[],\"flags\":[]}" + ] + for sample in cases { + for feature in VeilAetherEngine.catalog { + let report = try VeilAetherEngine.run(feature.number,json:sample) + XCTAssertFalse(report.output.isEmpty,"\(feature.number) with input \(sample)") + } + } + } +} diff --git a/VeilLinkTests/VeilBackupArchiveInspectorTests.swift b/VeilLinkTests/VeilBackupArchiveInspectorTests.swift new file mode 100644 index 00000000..226d30b3 --- /dev/null +++ b/VeilLinkTests/VeilBackupArchiveInspectorTests.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest +import ZIPFoundation +@testable import VeilLink + +/// Real ZIPFoundation runtime integration tests; run only on the Governor's +/// macOS/Xcode iOS CI/device lane, not in the standalone Linux policy package. +final class VeilBackupArchiveInspectorTests: XCTestCase { + private func fixture(_ corrupted: Bool = false, omit: String? = nil) throws -> (URL, URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("VL-preflight-\(UUID())") + let staging = root.appendingPathComponent("Payload") + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let manifest = BackupManifest(format: "VeilLinkBackup", version: 2, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + identityID: UUID().uuidString, + salt: Data(repeating: 0x33, count: 16), iterations: 210_000) + let payload: [(String, Data)] = [ + ("manifest.json", try JSONEncoder().encode(manifest)), + ("database.enc", Data("encrypted-database-fixture".utf8)), + ("storage-key.enc", Data("encrypted-storage-key-fixture".utf8)), + ("identity.enc", Data("encrypted-identity-fixture".utf8)) + ] + for (name, bytes) in payload where name != omit { + try bytes.write(to: staging.appendingPathComponent(name), options: .atomic) + } + try FileManager.default.createDirectory(at: staging.appendingPathComponent("attachments"), + withIntermediateDirectories: true) + let archiveURL = root.appendingPathComponent("test.zip") + try FileManager.default.zipItem(at: staging, to: archiveURL, shouldKeepParent: false) + if corrupted { + var data = try Data(contentsOf: archiveURL) + data.removeLast(min(40, data.count / 2)) + try data.write(to: archiveURL) + } + return (root, archiveURL) + } + + func testGeneratedVeilLinkShapePassesMetadata() throws { + let (root, file) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let report = VeilBackupArchiveInspector.inspect(file, verifyAllCRC: false) + XCTAssertTrue(report.permittedToRestore, "\(report.problems)") + XCTAssertEqual(report.version, 2) + XCTAssertFalse(report.crcVerified) + XCTAssertTrue(report.safetyLabel.contains("尚未")) + } + + func testGeneratedVeilLinkShapePassesFullCRC() throws { + let (root, file) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let report = VeilBackupArchiveInspector.inspect(file, verifyAllCRC: true) + XCTAssertTrue(report.permittedToRestore, "\(report.problems)") + XCTAssertTrue(report.crcVerified) + } + + func testRequiredFileMissingRejectsWithoutRestore() throws { + let (root, file) = try fixture(omit: "database.enc") + defer { try? FileManager.default.removeItem(at: root) } + let report = VeilBackupArchiveInspector.inspect(file, verifyAllCRC: false) + XCTAssertFalse(report.permittedToRestore) + XCTAssertTrue(report.problems.contains(.missingRequiredFile)) + } + + func testTruncatedArchiveFailsClosed() throws { + let (root, file) = try fixture(corrupted: true) + defer { try? FileManager.default.removeItem(at: root) } + let report = VeilBackupArchiveInspector.inspect(file, verifyAllCRC: true) + XCTAssertFalse(report.permittedToRestore) + } + + func testNonZipRejected() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("not-zip-\(UUID()).zip") + defer { try? FileManager.default.removeItem(at: url) } + try Data("not a zip".utf8).write(to: url) + XCTAssertFalse(VeilBackupArchiveInspector.inspect(url, verifyAllCRC: false).permittedToRestore) + } +} diff --git a/VeilLinkTests/VeilBackupPreflightPolicyTests.swift b/VeilLinkTests/VeilBackupPreflightPolicyTests.swift new file mode 100644 index 00000000..73d75f52 --- /dev/null +++ b/VeilLinkTests/VeilBackupPreflightPolicyTests.swift @@ -0,0 +1,116 @@ +import Foundation +import XCTest +@testable import VeilBackupPreflight + +final class VeilBackupPreflightPolicyTests: XCTestCase { + private typealias P = VeilBackupPreflightPolicy + private func f(_ name: String, compressed: UInt64 = 1024, expanded: UInt64 = 2048) -> P.EntryInfo { + .init(path: name, kind: .file, compressedBytes: compressed, expandedBytes: expanded) + } + private var healthy: [P.EntryInfo] { + [f("manifest.json"), f("database.enc"), f("storage-key.enc"), f("identity.enc"), + .init(path: "attachments/", kind: .directory, compressedBytes: 0, expandedBytes: 0)] + } + private func evaluate(_ entries: [P.EntryInfo]) -> P.Report { P.evaluate(entries, zipBytes: 64_000) } + + func testOrdinaryBackupPassesButDoesNotPretendDecryption() { + let report = evaluate(healthy) + XCTAssertTrue(report.permittedToRestore) + XCTAssertFalse(report.crcVerified) + XCTAssertEqual(report.entries, 5) + XCTAssertEqual(report.attachments, 0) + XCTAssertNil(report.version) + } + + func testAttachmentUUIDAllowedAndCounted() { + let additional = f("attachments/DEADBEEF-DEAD-4F00-8000-111111111111.bin") + let report = evaluate(healthy + [additional]) + XCTAssertTrue(report.permittedToRestore) + XCTAssertEqual(report.attachments, 1) + } + + func testMissingManifestFails() { + let report = evaluate(healthy.filter { $0.path != "manifest.json" }) + XCTAssertTrue(report.problems.contains(.missingRequiredFile)) + } + + func testDuplicateRequiredFileFails() { + let report = evaluate(healthy + [f("manifest.json")]) + XCTAssertTrue(report.problems.contains(.duplicatePath)) + } + + func testCaseFoldAttachmentCollisionFails() { + let id = "DEADBEEF-DEAD-4F00-8000-111111111111" + let report = evaluate(healthy + [f("attachments/\(id).bin"), f("attachments/\(id.lowercased()).bin")]) + XCTAssertTrue(report.problems.contains(.duplicatePath)) + } + + func testAbsoluteParentTraversalAndBackslashesRejected() { + for bad in ["../manifest.json", "/manifest.json", "attachments/../../identity.enc", "attachments\\123.bin", "C:/manifest.json"] { + var list = healthy + list.append(f(bad)) + XCTAssertFalse(evaluate(list).permittedToRestore, bad) + } + } + + func testSymlinkAndUnknownTypeRejected() { + for kind in [P.Kind.symlink, .other] { + let injected = P.EntryInfo(path: "manifest.json", kind: kind, compressedBytes: 1, expandedBytes: 1) + let report = evaluate(healthy + [injected]) + XCTAssertTrue(report.problems.contains(.symlink)) + } + } + + func testCompressedSizeZeroWithPayloadRejected() { + let report = evaluate(healthy + [f("attachments/DEADBEEF-DEAD-4F00-8000-111111111111.bin", compressed: 0, expanded: 2)]) + XCTAssertTrue(report.problems.contains(.implausibleCompression)) + } + + func testExcessiveExpansionRatioRejected() { + let report = evaluate(healthy + [f("database.enc", compressed: 300, expanded: 4 * 1024 * 1024)]) + XCTAssertTrue(report.problems.contains(.implausibleCompression)) + } + + func testOversizedArchiveRejectedBeforeUnzip() { + let report = P.evaluate(healthy, zipBytes: P.maxArchiveBytes + 1) + XCTAssertTrue(report.problems.contains(.archiveTooLarge)) + } + + func testAggregateExpansionBound() { + let report = P.evaluate(healthy + [f("database.enc", compressed: 64 * 1024 * 1024, + expanded: P.maxExpandedBytes + 1)], zipBytes: P.maxArchiveBytes) + XCTAssertTrue(report.problems.contains(.tooLargeExpanded)) + } + + func testDeclaredCompressedContentLargerThanArchiveRejected() { + let report = P.evaluate(healthy + [f("database.enc", compressed: 100_000, expanded: 100_000)], zipBytes: 24_000) + XCTAssertTrue(report.problems.contains(.invalidEntrySize)) + } + + func testUnsafeNamesAndExtraRootRejected() { + for bad in ["metadata.json", "attachments/abc.txt", "attachments//x.bin", "./manifest.json", "manifest.json/", "attachments"] { + XCTAssertFalse(evaluate(healthy + [f(bad)]).permittedToRestore, bad) + } + } + + func testTooManyEntries() { + let attachment = f("attachments/DEADBEEF-DEAD-4F00-8000-111111111111.bin", compressed: 1, expanded: 1) + let report = P.evaluate(healthy + Array(repeating: attachment, count: P.maxEntryCount), zipBytes: 80_000) + XCTAssertTrue(report.problems.contains(.tooManyEntries)) + } + + func testReportProblemsAreDistinctAndPreserved() { + let first = evaluate(healthy) + let next = first.appending([.invalidManifest, .invalidManifest], version: 2, createdAt: Date(timeIntervalSince1970: 100)) + XCTAssertEqual(next.problems, [.invalidManifest]) + XCTAssertFalse(next.permittedToRestore) + XCTAssertEqual(next.version, 2) + } + + func testNoCopyOfPrivatePathInDiagnosticLabels() { + for p in P.Problem.allCases { + XCTAssertFalse(p.explanation.contains("/private/")) + XCTAssertFalse(p.explanation.isEmpty) + } + } +} diff --git a/VeilLinkTests/VeilEmotionGardenEngineTests.swift b/VeilLinkTests/VeilEmotionGardenEngineTests.swift new file mode 100644 index 00000000..227807f0 --- /dev/null +++ b/VeilLinkTests/VeilEmotionGardenEngineTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import VeilLink + +final class VeilEmotionGardenEngineTests: XCTestCase { + func testRuntimeContainsOneThousandUniqueLocalRituals() { + XCTAssertEqual(VeilEmotionGardenEngine.rituals.count, 1_000) + XCTAssertEqual(Set(VeilEmotionGardenEngine.rituals.map(\.id)).count, 1_000) + XCTAssertEqual(Set(VeilEmotionGardenEngine.rituals.map(\.prompt)).count, 1_000) + XCTAssertEqual(Set(VeilEmotionGardenEngine.rituals.map { $0.steps.joined(separator: "|") }).count, 1_000) + XCTAssertEqual(Set(VeilEmotionGardenEngine.rituals.map(\.completionNote)).count, 1_000) + XCTAssertTrue(VeilEmotionGardenEngine.rituals.allSatisfy { $0.seconds > 0 && !$0.prompt.isEmpty && $0.steps.count == 3 }) + XCTAssertEqual(VeilEmotionGardenEngine.rituals.first?.id, "EV-0001") + XCTAssertEqual(VeilEmotionGardenEngine.rituals.last?.id, "EV-1000") + XCTAssertEqual(VeilEmotionGardenEngine.ritual(id: "EV-1000")?.tone, .playful) + } + + func testEachToneHasOneHundredRitualsAndEveryContextIsReachable() { + for tone in VeilEmotionTone.allCases { + let rituals = VeilEmotionGardenEngine.rituals(for: tone) + XCTAssertEqual(rituals.count, 100) + XCTAssertEqual(Set(rituals.map(\.kind)).count, 10) + XCTAssertEqual(Set(rituals.map(\.context)).count, 10) + XCTAssertTrue(rituals.allSatisfy { $0.tone == tone }) + for context in VeilEmotionContext.allCases { + let reachable = VeilEmotionGardenEngine.rituals(for: tone, context: context) + XCTAssertEqual(reachable.count, 10) + XCTAssertEqual(Set(reachable.map(\.kind)).count, 10) + } + } + } + + func testDailyPathIsDeterministicAndBounded() { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(secondsFromGMT: 0)! + let day = Date(timeIntervalSince1970: 1_704_067_200) + let first = VeilEmotionGardenEngine.dailyPath(for: .uncertain, context: .night, date: day, calendar: utc) + let second = VeilEmotionGardenEngine.dailyPath(for: .uncertain, context: .night, date: day, calendar: utc) + + XCTAssertEqual(first, second) + XCTAssertEqual(first.count, 3) + XCTAssertTrue(first.allSatisfy { $0.tone == .uncertain }) + XCTAssertTrue(first.allSatisfy { $0.context == .night }) + XCTAssertEqual(Set(first.map(\.id)).count, 3) + } + + func testSupportiveNotesArePresentAndDoNotClaimTreatment() { + for tone in VeilEmotionTone.allCases { + let note = VeilEmotionGardenEngine.supportiveNote(for: tone) + XCTAssertFalse(note.isEmpty) + XCTAssertFalse(note.localizedCaseInsensitiveContains("治疗")) + XCTAssertFalse(note.localizedCaseInsensitiveContains("诊断")) + } + } + + func testBreathingPromptHasClearPacingAndShortDuration() { + let ritual = VeilEmotionGardenEngine.rituals.first { $0.tone == .tense && $0.kind == .breath && $0.context == .morning } + XCTAssertEqual(ritual?.seconds, 60) + XCTAssertTrue(ritual?.prompt.contains("吸气") == true) + XCTAssertTrue(ritual?.prompt.contains("呼气") == true) + } + + func testIndexedLookupAndSearchCoverTheWholeLibrary() { + XCTAssertEqual(VeilEmotionGardenEngine.ritual(id: "ev-1000")?.id, "EV-1000") + XCTAssertEqual(VeilEmotionGardenEngine.search("EV-0001").map(\.id), ["EV-0001"]) + let rainy = VeilEmotionGardenEngine.search("雨天", limit: 100) + XCTAssertEqual(rainy.count, 100) + XCTAssertTrue(rainy.allSatisfy { $0.context == .rainyDay }) + XCTAssertTrue(VeilEmotionGardenEngine.search("不存在的情绪条目").isEmpty) + XCTAssertTrue(VeilEmotionGardenEngine.search("雨天", limit: 0).isEmpty) + } +} diff --git a/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift b/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift new file mode 100644 index 00000000..70eb3fc4 --- /dev/null +++ b/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import VeilMegaEngines + +final class VeilEntertainmentFeatureGateTests: XCTestCase { + typealias Gate = VeilEntertainmentFeatureGate + + func testAllTwoHundredCanonicalIdentifiers() { + let ids = (1...200).compactMap { Gate.id(for: $0) } + XCTAssertEqual(ids.count, 200) + XCTAssertEqual(Set(ids).count, 200) + XCTAssertEqual(ids.first, "E01") + XCTAssertEqual(ids[89], "E90") + XCTAssertEqual(ids[90], "E091") + XCTAssertEqual(ids.last, "E200") + } + func testInvalidIdsRejected() { + for id in ["E0", "E1", "E001", "E090", "E201", "E-1", "", "Z191"] { + XCTAssertNil(Gate.number(for: id)) + } + } + func testCandidateSetExactlyEight() { + let candidates = (1...200).compactMap { Gate.id(for: $0) }.filter { Gate.state(for: $0) == .candidateCore } + XCTAssertEqual(candidates.count, 8) + XCTAssertTrue(candidates.contains("E191")) + XCTAssertEqual(Gate.state(for: "E200"), .designed) + } + func testSafeByDefault() { + for i in 1...200 { + XCTAssertFalse(Gate.canPresent(Gate.id(for: i)!)) + } + } + func testPartialEvidenceCannotEnable() { + let e = Gate.Evidence(productCompiled: true, incidentP0Cleared: true, + ios15DevicePassed: false, userOptedIn: true) + XCTAssertFalse(Gate.canPresent("E191", evidence: e)) + } + func testDesignedFeatureCannotEnableWithAllProofs() { + let e = Gate.Evidence(productCompiled: true, incidentP0Cleared: true, + ios15DevicePassed: true, userOptedIn: true) + XCTAssertFalse(Gate.canPresent("E200", evidence: e)) + XCTAssertTrue(Gate.canPresent("E191", evidence: e)) + } +} diff --git a/VeilLinkTests/VeilEntertainmentR5IntegrationSmokeTests.swift b/VeilLinkTests/VeilEntertainmentR5IntegrationSmokeTests.swift new file mode 100644 index 00000000..5b2fed16 --- /dev/null +++ b/VeilLinkTests/VeilEntertainmentR5IntegrationSmokeTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import VeilLink + +/// Source-inclusive integration smoke tests. Require Governor to import the +/// R4 VeilRhythmExpedition dependency before compiling this test target. +final class VeilEntertainmentR5IntegrationSmokeTests: XCTestCase { + func testRhythmRequiresActualTapTime() throws { + var game = VeilRhythmLiveConductor(seed: 20261025) + try game.start(at: 100) + let lane = try XCTUnwrap(game.session.expectedNote?.lane) + XCTAssertThrowsError(try game.tap(lane: lane, at: 100)) + XCTAssertEqual(game.session.score, 0) + XCTAssertEqual(try game.tap(lane: lane, at: 101.6), .hit(.perfect)) + } + + func testRhythmPauseDoesNotAutoMiss() throws { + var game = VeilRhythmLiveConductor(seed: 5) + try game.start(at: 0) + try game.pause(at: 1) + XCTAssertEqual(try game.advance(to: 200), 0) + try game.resume(at: 201) + XCTAssertEqual(game.session.nextBeat, 0) + } + + func testStationRequiresDispatchBeforeDayClose() { + var game = VeilStationChronicle.Game(seed: 1) + XCTAssertThrowsError(try game.apply(.closeDay)) + XCTAssertEqual(game.day, 0) + XCTAssertEqual(game.funds, 240) + } + + func testStationReplayReconstructsState() throws { + var game = VeilStationChronicle.Game(seed: 9) + try game.apply(.undertake(.archive)) + try game.apply(.dispatch) + try game.apply(.closeDay) + let copy = try VeilStationChronicle.importReplay(game.exportReplay()) + XCTAssertEqual(copy.events, game.events) + XCTAssertEqual(copy.funds, game.funds) + } + + func testStationTwentyOriginalScenes() { + XCTAssertEqual(VeilStationChronicle.dayStories.count, 20) + XCTAssertEqual(Set(VeilStationChronicle.dayStories).count, 20) + } + + func testStationRejectsMalformedImport() { + XCTAssertThrowsError(try VeilStationChronicle.importReplay(Data("{}".utf8))) + } +} diff --git a/VeilLinkTests/VeilFeatureRegistryTests.swift b/VeilLinkTests/VeilFeatureRegistryTests.swift new file mode 100644 index 00000000..1a25f5a6 --- /dev/null +++ b/VeilLinkTests/VeilFeatureRegistryTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import VeilLink + +final class VeilFeatureRegistryTests: XCTestCase { + func testRejectsCaseInsensitiveDuplicateIDs() { + XCTAssertThrowsError(try VeilFeatureRegistry(features: [feature(id: "tool.one"), feature(id: "TOOL.ONE")])) { + XCTAssertEqual($0 as? VeilFeatureRegistryError, .duplicateID("TOOL.ONE")) + } + } + + func testIndexedLookupAndPagedQueryAreBounded() throws { + let features = (0..<250).map { feature(id: "tool.\($0)", domain: $0.isMultiple(of: 2) ? .utilities : .productivity) } + let registry = try VeilFeatureRegistry(features: features) + + XCTAssertEqual(registry.count, 250) + XCTAssertEqual(registry.feature(id: " TOOL.249 ")?.id, "tool.249") + let first = registry.query(VeilFeatureQuery(limit: 30)) + XCTAssertEqual(first.items.count, 30) + XCTAssertEqual(first.totalCount, 250) + XCTAssertTrue(first.hasMore) + let clamped = registry.query(VeilFeatureQuery(offset: 240, limit: 500)) + XCTAssertEqual(clamped.items.count, 10) + XCTAssertFalse(clamped.hasMore) + } + + func testSearchNormalizesCaseWidthAndDiacritics() throws { + let registry = try VeilFeatureRegistry(features: [ + feature(id: "emotion.cafe", title: "Café 安静陪伴", keywords: ["EMOTION"]), + feature(id: "game.rain", title: "雨线列车", domain: .games) + ]) + + XCTAssertEqual(registry.query(VeilFeatureQuery(text: "cafe")).items.map(\.id), ["emotion.cafe"]) + XCTAssertEqual(registry.query(VeilFeatureQuery(text: "emotion")).items.map(\.id), ["emotion.cafe"]) + XCTAssertEqual(registry.query(VeilFeatureQuery(text: "列车", domain: .games)).items.map(\.id), ["game.rain"]) + } + + func testLaunchGateFailsClosedWithReadableMissingChecks() throws { + let guarded = feature(id: "guarded", requiredGates: [.p0TouchVerified, .appleBuildVerified]) + let registry = try VeilFeatureRegistry(features: [guarded]) + let decision = registry.launchDecision( + for: "guarded", + profile: profile(), + passedGates: [.p0TouchVerified] + ) + + XCTAssertEqual(decision.state, .blocked) + XCTAssertFalse(decision.canLaunch) + XCTAssertTrue(decision.reason?.contains("Apple 构建") == true) + } + + func testLegacyDeviceAdaptsWhileGodModeForcesHighQuality() throws { + let registry = try VeilFeatureRegistry(features: [feature(id: "render", resourceClass: .renderHeavy)]) + let legacy = registry.launchDecision( + for: "render", + profile: profile(isLegacy: true), + passedGates: [] + ) + XCTAssertEqual(legacy.state, .degraded) + XCTAssertEqual(legacy.renderTier, .reduced) + + let god = registry.launchDecision( + for: "render", + profile: profile(isLegacy: true, godMode: true), + passedGates: [] + ) + XCTAssertEqual(god.state, .available) + XCTAssertEqual(god.renderTier, .high) + } + + func testCriticalThermalPressureDefersHeavyRenderEvenInGodMode() throws { + let registry = try VeilFeatureRegistry(features: [feature(id: "render", resourceClass: .renderHeavy)]) + let decision = registry.launchDecision( + for: "render", + profile: profile(godMode: true, thermal: .critical), + passedGates: [] + ) + XCTAssertEqual(decision.state, .thermallyDeferred) + XCTAssertFalse(decision.canLaunch) + } + + func testAuditCountsPacksAndSurfacesCrossPackConflictGroups() throws { + let registry = try VeilFeatureRegistry(features: [ + feature(id: "mission.a", pack: "mission-a", conflictGroup: "mission-control-r1"), + feature(id: "mission.b", pack: "mission-b", conflictGroup: "mission-control-r1"), + feature(id: "emotion.1", pack: "emotion") + ]) + let audit = registry.audit() + XCTAssertEqual(audit.featureCount, 3) + XCTAssertEqual(audit.packCounts["emotion"], 1) + XCTAssertEqual(audit.conflictGroups["mission-control-r1"], ["mission-a", "mission-b"]) + } + + private func feature( + id: String, + pack: String = "test-pack", + title: String = "测试功能", + domain: VeilFeatureDomain = .utilities, + keywords: [String] = [], + resourceClass: VeilFeatureResourceClass = .featherweight, + requiredGates: VeilFeatureGate = [], + conflictGroup: String? = nil + ) -> VeilFeatureDescriptor { + VeilFeatureDescriptor( + id: id, + packID: pack, + title: title, + subtitle: "用于注册表测试", + domain: domain, + keywords: keywords, + resourceClass: resourceClass, + requiredGates: requiredGates, + conflictGroup: conflictGroup, + isExperimental: false + ) + } + + private func profile( + isLegacy: Bool = false, + godMode: Bool = false, + thermal: VeilFeatureRuntimeProfile.ThermalPressure = .nominal + ) -> VeilFeatureRuntimeProfile { + VeilFeatureRuntimeProfile( + isLegacyDevice: isLegacy, + lowPowerMode: false, + reduceMotion: false, + godModeHighQuality: godMode, + thermalPressure: thermal + ) + } +} diff --git a/VeilLinkTests/VeilHTMLImportPolicyTests.swift b/VeilLinkTests/VeilHTMLImportPolicyTests.swift new file mode 100644 index 00000000..4eca6596 --- /dev/null +++ b/VeilLinkTests/VeilHTMLImportPolicyTests.swift @@ -0,0 +1,41 @@ +import Foundation +import XCTest +@testable import VeilLink + +final class VeilHTMLImportPolicyTests: XCTestCase { + func testUTF8AndChinese() throws { + let html = "

离线资料

您好!

" + XCTAssertEqual(try VeilHTMLImportPolicy.decode(Data(html.utf8)), html) + XCTAssertEqual(try VeilHTMLImportPolicy.decode(Data([0xEF,0xBB,0xBF]) + Data(html.utf8)), html) + } + func testUTF16BOM() throws { + let text = "测试" + let raw = Data([0xFF,0xFE]) + (text.data(using: .utf16LittleEndian) ?? Data()) + XCTAssertEqual(try VeilHTMLImportPolicy.decode(raw), text) + } + func testRejectsEmptyAndOversized() throws { + XCTAssertThrowsError(try VeilHTMLImportPolicy.decode(Data())) + XCTAssertThrowsError(try VeilHTMLImportPolicy.decode(Data(repeating: 65, count: 512*1024+1))) + XCTAssertEqual(try VeilHTMLImportPolicy.decode(Data(repeating: 65, count: 512*1024)).count, 512*1024) + } + func testRejectsMalformedUTF8() throws { + XCTAssertThrowsError(try VeilHTMLImportPolicy.decode(Data([0xFF,0xFF,0xC0]))) + } + func testSafeLinksRejectActiveSchemes() { + XCTAssertEqual(VeilHTMLImportPolicy.safeExternalLink("https://example.org/post?q=你好"), "https://example.org/post?q=你好") + for url in ["javascript:alert(1)", "file:///tmp/private", "data:text/html,a", + "https://user:pw@example.org", "https://example.org\nnext", "https://"] { + XCTAssertNil(VeilHTMLImportPolicy.safeExternalLink(url), url) + } + XCTAssertNil(VeilHTMLImportPolicy.safeExternalLink(String(repeating: "a", count: 3000))) + } + func testBoundedAndSearch() { + XCTAssertEqual(VeilHTMLImportPolicy.bounded("ABCDE", limit: 3), "ABC") + XCTAssertTrue(VeilHTMLImportPolicy.isMatch("SwiftSoup 离线文档", query: " swiftsoup ")) + XCTAssertFalse(VeilHTMLImportPolicy.isMatch("本地安全", query: "网络")) + } + func testResultCap() { + let doc = VeilOfflineHTMLDocument(title: "T", sections: (0..<300).map { "会话 \($0)" }, links: [], truncated: false) + XCTAssertEqual(doc.matchingSections(query: "会话").count, 100) + } +} diff --git a/VeilLinkTests/VeilMissionControlEngineTests.swift b/VeilLinkTests/VeilMissionControlEngineTests.swift new file mode 100644 index 00000000..cee6cabf --- /dev/null +++ b/VeilLinkTests/VeilMissionControlEngineTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import VeilLink + +final class VeilMissionControlEngineTests: XCTestCase { + func testEveryFeatureHasAResult() { + let all = VeilMissionControlEngine.analyzeAll(VeilMissionScenario.healthy.snapshot) + XCTAssertEqual(VeilMissionFeature.allCases.count, 20) + XCTAssertEqual(all.count, 20) + XCTAssertEqual(Set(all.map(\.id)).count, 20) + XCTAssertTrue(all.allSatisfy { !$0.headline.isEmpty && !$0.explanation.isEmpty }) + } + + func testRouteShortestAndAlternate() { + let s = VeilMissionScenario.healthy.snapshot + let primary = VeilMissionControlEngine.shortestRoute(s.edges, source: "self", destination: "target") + XCTAssertEqual(primary?.0, ["self", "P1", "target"]) + XCTAssertEqual(primary?.1, 4) + let secondary = VeilMissionControlEngine.shortestRoute(s.edges, source: "self", + destination: "target", avoiding: ["P1"]) + XCTAssertEqual(secondary?.0, ["self", "P2", "target"]) + XCTAssertNil(VeilMissionControlEngine.shortestRoute([], source: "self", destination: "target")) + } + + func testRejectsInvalidGraphCostsAndLoops() { + let invalid = [ + VeilMissionEdge(from: "A", to: "B", cost: -.infinity), + VeilMissionEdge(from: "A", to: "A", cost: -1) + ] + XCTAssertNil(VeilMissionControlEngine.shortestRoute(invalid, source: "A", destination: "B")) + XCTAssertEqual( + VeilMissionControlEngine.shortestRoute([], source: "A", destination: "A")?.0, ["A"]) + } + + func testFailClosedWhenDatabaseKeyIsUnavailable() { + let report = VeilMissionControlEngine.analyze(.keyReadiness, + snapshot: VeilMissionScenario.recovery.snapshot) + XCTAssertEqual(report.severity, .blocked) + XCTAssertEqual(report.value, "拒绝重置") + } + + func testWeakLinkIsNotTreatedAsReadyForFullCapabilities() { + let s = VeilMissionScenario.degraded.snapshot + XCTAssertNotEqual(VeilMissionControlEngine.analyze(.linkHealth, snapshot: s).severity, .normal) + XCTAssertEqual(VeilMissionControlEngine.analyze(.capabilityFallback, snapshot: s).severity, .caution) + XCTAssertEqual(VeilMissionControlEngine.analyze(.transportSelection, snapshot: s).value, "BLE") + } + + func testRecoveryDoesNotPromiseCompletedIntegrity() { + let report = VeilMissionControlEngine.analyze(.transferRecovery, + snapshot: VeilMissionScenario.recovery.snapshot) + XCTAssertTrue(report.explanation.contains("认证")) + XCTAssertTrue(report.value.contains("待验证")) + } + + func testReplaySequencesAndTouchOcclusion() { + let healthy = VeilMissionScenario.healthy.snapshot + let poor = VeilMissionScenario.degraded.snapshot + XCTAssertEqual(VeilMissionControlEngine.analyze(.replayGuard, snapshot: healthy).severity, .normal) + XCTAssertEqual(VeilMissionControlEngine.analyze(.replayGuard, snapshot: poor).severity, .caution) + XCTAssertEqual(VeilMissionControlEngine.analyze(.touchOcclusion, snapshot: poor).value, "1 处") + } + + func testThermalPrivacyBatteryWarnings() { + let poor = VeilMissionScenario.degraded.snapshot + for feature in [VeilMissionFeature.thermalGuard, .batteryReserve, .privacyFootprint] { + XCTAssertEqual(VeilMissionControlEngine.analyze(feature, snapshot: poor).severity, .caution) + } + } + + func testQueueAndPacketMath() { + let healthy = VeilMissionScenario.healthy.snapshot + XCTAssertEqual(VeilMissionControlEngine.analyze(.packetSizing, snapshot: healthy).value, "182 B") + XCTAssertEqual(VeilMissionControlEngine.analyze(.congestionControl, snapshot: healthy).value, "8 → 9") + XCTAssertEqual(VeilMissionControlEngine.analyze(.retryPacing, snapshot: healthy).value, "2 s") + } + + func testDeterministicAcrossRepeatedRuns() { + let s = VeilMissionScenario.degraded.snapshot + XCTAssertEqual(VeilMissionControlEngine.analyzeAll(s), VeilMissionControlEngine.analyzeAll(s)) + } +} diff --git a/VeilLinkTests/VeilOfflineHTMLImporterTests.swift b/VeilLinkTests/VeilOfflineHTMLImporterTests.swift new file mode 100644 index 00000000..ac38ea51 --- /dev/null +++ b/VeilLinkTests/VeilOfflineHTMLImporterTests.swift @@ -0,0 +1,43 @@ +import Foundation +import XCTest +@testable import VeilLink + +/// Requires SwiftSoup 2.9.6 on the approved iOS integration branch. +final class VeilOfflineHTMLImporterTests: XCTestCase { + func testExtractsChineseArticleAndInertLinks() throws { + let html = """ + 离线阅读 +

第一章

你好 & 世界。

+ 资料链接 + 脚本链接 + """ + let doc = try VeilOfflineHTMLImporter.parse(Data(html.utf8)) + XCTAssertEqual(doc.title, "离线阅读") + XCTAssertTrue(doc.sections.contains("第一章")) + XCTAssertTrue(doc.sections.contains("你好 & 世界。")) + XCTAssertFalse(doc.sections.joined().contains("danger")) + XCTAssertFalse(doc.sections.joined().contains("跳过导航")) + XCTAssertEqual(doc.links.map(\.address), ["https://example.org/a"]) + } + + func testDeduplicatesLinksAndSkipsFrames() throws { + let source = """ + 测试

正文

+ 一二 + + """ + let doc = try VeilOfflineHTMLImporter.parse(Data(source.utf8)) + XCTAssertEqual(doc.links.count, 1) + XCTAssertTrue(doc.sections.contains("正文")) + } + + func testFallbackForPlainBody() throws { + let doc = try VeilOfflineHTMLImporter.parse(Data("Plain content 中文".utf8)) + XCTAssertEqual(doc.sections, ["Plain content 中文"]) + } + + func testEmptyBodyAndOversizeRejected() throws { + XCTAssertThrowsError(try VeilOfflineHTMLImporter.parse(Data("".utf8))) + XCTAssertThrowsError(try VeilOfflineHTMLImporter.parse(Data(repeating: 65, count: VeilHTMLImportPolicy.maximumSourceBytes + 1))) + } +} diff --git a/VeilLinkTests/VeilPairCameraInputGateTests.swift b/VeilLinkTests/VeilPairCameraInputGateTests.swift new file mode 100644 index 00000000..46a072a8 --- /dev/null +++ b/VeilLinkTests/VeilPairCameraInputGateTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import VeilLink + +final class VeilPairCameraInputGateTests: XCTestCase { + private let prefix = "veillink://pair?v=1&d=" + + func testSyntheticBase64URLPassesInputShapeOnly() { + XCTAssertTrue(VeilPairCameraInputGate.accepts(prefix + "Abc12345-_")) + // Valid input shape MUST NOT be mistaken for an authenticated QR. + } + + func testRejectsOtherSchemesAndWrongVersion() { + XCTAssertFalse(VeilPairCameraInputGate.accepts("https://example.org")) + XCTAssertFalse(VeilPairCameraInputGate.accepts("veillink://pair?v=2&d=Abc12345")) + XCTAssertFalse(VeilPairCameraInputGate.accepts("veillink://pair?v=1&d=")) + } + + func testRejectsWhitespaceAndUntrustedAlphabet() { + XCTAssertFalse(VeilPairCameraInputGate.accepts(prefix + "Abc12345\n")) + XCTAssertFalse(VeilPairCameraInputGate.accepts(prefix + "Abc12345%")) + XCTAssertFalse(VeilPairCameraInputGate.accepts(prefix + "Abc12345/")) + XCTAssertFalse(VeilPairCameraInputGate.accepts(prefix + "中文123456")) + } + + func testPreventsOversizedScannerPayload() { + XCTAssertFalse(VeilPairCameraInputGate.accepts(prefix + String(repeating: "A", count: 4_096))) + let exact = String(repeating: "A", count: VeilPairCameraInputGate.maximumUTF8Bytes - prefix.utf8.count) + XCTAssertTrue(VeilPairCameraInputGate.accepts(prefix + exact)) + } +} diff --git a/VeilLinkTests/VeilProductivityEngineTests.swift b/VeilLinkTests/VeilProductivityEngineTests.swift new file mode 100644 index 00000000..a8fc2cf0 --- /dev/null +++ b/VeilLinkTests/VeilProductivityEngineTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import VeilLink + +final class VeilProductivityEngineTests: XCTestCase { + func testTemplateFindsUniqueVariablesAndRejectsMissingValues() throws { + let source = "{{name}} / {{ file }} / {{name}}" + XCTAssertEqual(try VeilProductivityEngine.analyzeTemplate(source).variables, ["name", "file"]) + XCTAssertThrowsError(try VeilProductivityEngine.renderTemplate(source, values: ["name": "A"])) { + XCTAssertEqual($0 as? VeilProductivityError, .missingVariables(["file"])) + } + XCTAssertEqual( + try VeilProductivityEngine.renderTemplate(source, values: ["name": "A", "file": "B"]), + "A / B / A" + ) + } + + func testTemplateRejectsMalformedPlaceholder() throws { + XCTAssertTrue(try VeilProductivityEngine.analyzeTemplate("hello {{name").malformed) + XCTAssertTrue(try VeilProductivityEngine.analyzeTemplate("hello }}").malformed) + XCTAssertThrowsError(try VeilProductivityEngine.renderTemplate("{{ }}", values: [:])) { + XCTAssertEqual($0 as? VeilProductivityError, .malformedPlaceholder) + } + } + + func testLineDiffProducesStableEditScript() throws { + XCTAssertEqual( + try VeilProductivityEngine.diff(old: "one\ntwo\nthree", new: "one\nTWO\nthree\nfour"), + [ + VeilDiffLine(kind: .unchanged, text: "one"), + VeilDiffLine(kind: .added, text: "TWO"), + VeilDiffLine(kind: .removed, text: "two"), + VeilDiffLine(kind: .unchanged, text: "three"), + VeilDiffLine(kind: .added, text: "four") + ] + ) + } + + func testDiffHasHardLineAndByteLimits() { + let tooManyLines = Array(repeating: "x", count: 401).joined(separator: "\n") + XCTAssertThrowsError(try VeilProductivityEngine.diff(old: tooManyLines, new: "")) { + XCTAssertEqual($0 as? VeilProductivityError, .diffTooLarge) + } + XCTAssertThrowsError(try VeilProductivityEngine.diff(old: String(repeating: "x", count: 65_537), new: "")) { + XCTAssertEqual($0 as? VeilProductivityError, .diffTooLarge) + } + } + + func testThreeWayMergeOnlyAutoResolvesUnambiguousCases() throws { + XCTAssertEqual( + try VeilProductivityEngine.threeWayMerge(base: "a", local: "a", remote: "b"), + VeilMergeResult(text: "b", hasConflict: false) + ) + let conflict = try VeilProductivityEngine.threeWayMerge(base: "a", local: "b", remote: "c") + XCTAssertTrue(conflict.hasConflict) + XCTAssertTrue(conflict.text.contains("<<<<<<< 本机")) + XCTAssertTrue(conflict.text.contains(">>>>>>> 对端")) + } + + func testMeetingPlannerRanksMutuallyComfortableSlotFirst() throws { + let participants = [ + VeilTimeZoneParticipant(label: "UTC", timeZoneIdentifier: "UTC", preferredStartHour: 9, preferredEndHour: 18), + VeilTimeZoneParticipant(label: "Berlin", timeZoneIdentifier: "Europe/Berlin", preferredStartHour: 9, preferredEndHour: 18) + ] + let start = Date(timeIntervalSince1970: 1_704_067_200) // 2024-01-01 00:00 UTC + let slots = try VeilProductivityEngine.meetingSlots(from: start, participants: participants, days: 1, limit: 4) + XCTAssertEqual(slots.count, 4) + XCTAssertTrue(slots.allSatisfy { $0.comfortableParticipantCount == 2 }) + XCTAssertEqual(slots.first?.date, Date(timeIntervalSince1970: 1_704_096_000)) // 08:00 UTC, 09:00 Berlin + } + + func testMeetingPlannerRejectsUnknownTimeZone() { + let participant = VeilTimeZoneParticipant(label: "bad", timeZoneIdentifier: "Moon/Sea", preferredStartHour: 9, preferredEndHour: 18) + XCTAssertThrowsError(try VeilProductivityEngine.meetingSlots(from: Date(), participants: [participant])) { + XCTAssertEqual($0 as? VeilProductivityError, .invalidTimeZone("Moon/Sea")) + } + } + + func testImportPreflightBlocksTraversalDuplicatesAndTypeMismatch() throws { + let result = try VeilProductivityEngine.importPreflight( + items: [ + VeilImportItem(name: "../secret.txt", byteCount: 12), + VeilImportItem(name: "Résumé.TXT", byteCount: 10), + VeilImportItem(name: "resume.txt", byteCount: 0), + VeilImportItem(name: "image.exe", byteCount: 4) + ], + allowedExtensions: ["txt"] + ) + XCTAssertFalse(result.canImport) + XCTAssertEqual(result.totalBytes, 26) + XCTAssertTrue(result.issues.contains { $0.message == "文件名不安全" }) + XCTAssertTrue(result.issues.contains { $0.message == "规范化后文件名重复" }) + XCTAssertTrue(result.issues.contains { $0.message == "不允许的文件类型" }) + XCTAssertTrue(result.issues.contains { $0.severity == .warning && $0.message == "空文件" }) + } + + func testImportPreflightAcceptsSafeManifest() throws { + let result = try VeilProductivityEngine.importPreflight( + items: [VeilImportItem(name: "notes.txt", byteCount: 128)], + allowedExtensions: ["txt"] + ) + XCTAssertTrue(result.canImport) + XCTAssertEqual(result.itemCount, 1) + XCTAssertEqual(result.totalBytes, 128) + XCTAssertTrue(result.issues.isEmpty) + } +} diff --git a/VeilLinkTests/VeilR6FramePacerTests.swift b/VeilLinkTests/VeilR6FramePacerTests.swift new file mode 100644 index 00000000..c5fbc2d4 --- /dev/null +++ b/VeilLinkTests/VeilR6FramePacerTests.swift @@ -0,0 +1,57 @@ +import XCTest +#if canImport(VeilMegaEngines) +@testable import VeilMegaEngines +#elseif canImport(VeilLink) +@testable import VeilLink +#endif + +final class VeilR6FramePacerTests: XCTestCase { + func testFirstFrameHasNoTimeJump() { + var p=VeilFramePacer() + XCTAssertEqual(p.advance(now:200)?.simulationSteps,0) + } + func testSixtyFramesIsOneSecond() { + var p=VeilFramePacer() + _ = p.advance(now:0) + for i in 1...60 { _ = p.advance(now:Double(i)/60) } + XCTAssertEqual(p.totalSimSteps,60) + } + func testThirtyFPSStillHasSixtyLogicSteps() { + var p=VeilFramePacer() + _ = p.advance(now:0) + for i in 1...30 { _ = p.advance(now:Double(i)/30) } + XCTAssertEqual(p.totalSimSteps,60) + } + func testBackgroundGapIsCappedAndResetSafe() { + var p=VeilFramePacer() + _ = p.advance(now:0) + let step=p.advance(now:10000)! + XCTAssertLessThanOrEqual(step.simulationSteps,4) + XCTAssertGreaterThan(step.droppedSeconds,9999) + p.reset() + XCTAssertEqual(p.advance(now:10001)?.simulationSteps,0) + } + func testInvalidTimeDoesNotCorruptClock() { + var p=VeilFramePacer() + _ = p.advance(now:5) + XCTAssertNil(p.advance(now:.nan)) + XCTAssertNil(p.advance(now:4)) + XCTAssertEqual(p.advance(now:5+1.0/60)?.simulationSteps,1) + } + func testQualityBudgetsBoundMemoryAndParticles() { + let low=VeilRenderBudget(tier:.legacy) + let high=VeilRenderBudget(tier:.high) + XCTAssertEqual(low.targetFPS,30) + XCTAssertEqual(low.particleBudget,0) + XCTAssertLessThan(low.maxSprites,high.maxSprites) + } + func testInterpolationAlwaysValid() { + var p=VeilFramePacer() + _=p.advance(now:0) + for i in 1...200 { + let step=p.advance(now:Double(i)/41)! + XCTAssertGreaterThanOrEqual(step.interpolation,0) + XCTAssertLessThanOrEqual(step.interpolation,1) + } + } +} diff --git a/VeilLinkTests/VeilR6RenderGeometryTests.swift b/VeilLinkTests/VeilR6RenderGeometryTests.swift new file mode 100644 index 00000000..be7b9b52 --- /dev/null +++ b/VeilLinkTests/VeilR6RenderGeometryTests.swift @@ -0,0 +1,67 @@ +import XCTest +#if canImport(VeilMegaEngines) +@testable import VeilMegaEngines +#elseif canImport(VeilLink) +@testable import VeilLink +#endif + +final class VeilR6RenderGeometryTests: XCTestCase { + typealias G = VeilRenderGeometry + func testNoAspectDistortionAcrossScreens() throws { + let devices = [G.Size(375,667), G.Size(390,844), G.Size(428,926), G.Size(768,1024), G.Size(1024,768), G.Size(320,568), G.Size(800,600), G.Size(390,390)] + for device in devices { + let p = try G.fit(canvas: .init(390,600), in: device) + XCTAssertEqual(p.aspectDistortion, 0, accuracy: 1e-9) + XCTAssertLessThanOrEqual(p.viewport.width, device.width+1e-9) + XCTAssertLessThanOrEqual(p.viewport.height, device.height+1e-9) + } + } + func testRoundTripScreenTouchMapping() throws { + for device in [G.Size(375,667), G.Size(390,844), G.Size(768,1024), G.Size(1024,768)] { + let p = try G.fit(canvas: .init(390,600), in: device) + for pt in [G.Point(0,0), G.Point(100,200), G.Point(195,300), G.Point(390,600)] { + let roundtrip = try XCTUnwrap(p.toLogical(p.toView(pt))) + XCTAssertEqual(roundtrip.x, pt.x, accuracy: 1e-7) + XCTAssertEqual(roundtrip.y, pt.y, accuracy: 1e-7) + } + } + } + func testLetterboxIsNotInteractive() throws { + let p = try G.fit(canvas: .init(390,600), in: .init(1024,768)) + XCTAssertNil(p.toLogical(.init(0,0))) + XCTAssertNil(p.toLogical(.init(1024,768))) + XCTAssertNotNil(p.toLogical(p.toView(.init(195,300)))) + } + func testSafeInsetsAreRespected() throws { + let p = try G.fit(canvas: .init(390,600), in: .init(390,844), insets: .init(top: 47, bottom: 34)) + XCTAssertGreaterThanOrEqual(p.viewport.y,47) + XCTAssertLessThanOrEqual(p.viewport.y+p.viewport.height,810) + } + func testRejectInvalidDimensionsAndInsets() throws { + XCTAssertThrowsError(try G.fit(canvas:.init(0,600),in:.init(300,500))) + XCTAssertThrowsError(try G.fit(canvas:.init(390,600),in:.init(.nan,500))) + XCTAssertThrowsError(try G.fit(canvas:.init(390,600),in:.init(100,100),insets:.init(top:80,bottom:80))) + XCTAssertThrowsError(try G.fit(canvas:.init(390,600),in:.init(100,100),pixelsPerPoint:.infinity)) + } + func testSpriteKitYFlipsOnceOnly() throws { + let p = try G.fit(canvas: .init(390,600), in:.init(390,600)) + XCTAssertEqual(p.toSpriteKit(.init(200,80)),.init(200,520)) + XCTAssertEqual(p.toSpriteKit(.init(200,500)),.init(200,100)) + } + func testPixelSizeOnlyChangesDensity() throws { + let p1 = try G.fit(canvas: .init(390,600), in:.init(390,600),pixelsPerPoint:1) + let p3 = try G.fit(canvas: .init(390,600), in:.init(390,600),pixelsPerPoint:3) + XCTAssertEqual(p1.viewport,p3.viewport) + XCTAssertEqual(p3.drawablePixels.width,3*p1.drawablePixels.width) + } + func testFuzzViewportsAreFinite() throws { + for w in stride(from: 240.0, through: 1280.0, by: 17.0) { + for h in stride(from: 240.0, through: 930.0, by: 37.0) { + let p = try G.fit(canvas: .init(390,600), in:.init(w,h)) + XCTAssertTrue(p.scale.isFinite) + XCTAssertEqual(p.aspectDistortion,0,accuracy:1e-9) + XCTAssertNotNil(p.toLogical(p.toView(.init(195,300)))) + } + } + } +} diff --git a/VeilLinkTests/VeilR6VisualTimelineTests.swift b/VeilLinkTests/VeilR6VisualTimelineTests.swift new file mode 100644 index 00000000..f0c396ac --- /dev/null +++ b/VeilLinkTests/VeilR6VisualTimelineTests.swift @@ -0,0 +1,46 @@ +import XCTest +#if canImport(VeilMegaEngines) +@testable import VeilMegaEngines +#elseif canImport(VeilLink) +@testable import VeilLink +#endif + +final class VeilR6VisualTimelineTests: XCTestCase { + typealias V=VeilRhythmVisualTimeline + func testOneNoteAtHitLine() throws { + let note=try XCTUnwrap(V.notes(seed:7,district:0,nextBeat:0,secondsToNextBeat:0).first) + XCTAssertEqual(note.y,V.strikeY,accuracy:1e-8) + XCTAssertEqual(note.beat,0) + } + func testFutureNoteStartsAtSpawnLine() throws { + let note=try XCTUnwrap(V.notes(seed:7,district:0,nextBeat:0,secondsToNextBeat:1.6).first) + XCTAssertEqual(note.y,V.spawnY,accuracy:1e-8) + } + func testAllFourLaneCentersAreInsideCanvas() { + for seed in UInt64(0)..<80 { + let arr=V.notes(seed:seed,district:0,nextBeat:0,secondsToNextBeat:0.5) + XCTAssertFalse(arr.isEmpty) + for note in arr { + XCTAssertTrue((0..<4).contains(note.lane)) + XCTAssertGreaterThan(note.x,0) + XCTAssertLessThan(note.x,390) + XCTAssertGreaterThanOrEqual(note.y,V.spawnY) + XCTAssertLessThanOrEqual(note.y,V.strikeY) + } + } + } + func testInvalidFrameHasNoNotes() { + XCTAssertTrue(V.notes(seed:1,district:0,nextBeat:0,secondsToNextBeat:.nan).isEmpty) + XCTAssertTrue(V.notes(seed:1,district:-1,nextBeat:0,secondsToNextBeat:0).isEmpty) + XCTAssertTrue(V.notes(seed:1,district:0,nextBeat:16,secondsToNextBeat:0).isEmpty) + } + func testNoFutureLeakAcrossDistrict() { + let notes=V.notes(seed:1,district:3,nextBeat:15,secondsToNextBeat:1.2,capacity:10) + XCTAssertTrue(notes.allSatisfy { $0.beat==15 }) + } + func testClockAnimationInvariantAcrossFPS() { + let at60=V.notes(seed:3,district:1,nextBeat:2,secondsToNextBeat:0.42) + let at30=V.notes(seed:3,district:1,nextBeat:2,secondsToNextBeat:0.42) + XCTAssertEqual(at60,at30) + } +} diff --git a/VeilLinkTests/VeilR7FrameStatsTests.swift b/VeilLinkTests/VeilR7FrameStatsTests.swift new file mode 100644 index 00000000..6f9f9cb6 --- /dev/null +++ b/VeilLinkTests/VeilR7FrameStatsTests.swift @@ -0,0 +1,68 @@ +import XCTest +#if canImport(VeilMegaEngines) +@testable import VeilMegaEngines +#elseif canImport(VeilLink) +@testable import VeilLink +#endif + +final class VeilR7FrameStatsTests: XCTestCase { + func testEmptyReportAndInitialSample() { + var s = VeilRenderFrameStats() + XCTAssertEqual(s.report.sampleCount, 0) + s.observe(1) + XCTAssertEqual(s.report.sampleCount, 0) + } + func testThirtyFpsCadence() { + var s = VeilRenderFrameStats() + for i in 0...60 { s.observe(Double(i) / 30) } + XCTAssertEqual(s.report.sampleCount, 60) + XCTAssertEqual(s.report.medianIntervalMs, 1000.0/30.0, accuracy:0.001) + XCTAssertEqual(s.report.slowFrames, 0) + } + func testLongPauseNotCountedAsDroppedFrame() { + var s = VeilRenderFrameStats() + s.observe(1); s.observe(1.02); s.observe(200); s.observe(200.02) + XCTAssertEqual(s.report.sampleCount, 2) + XCTAssertEqual(s.report.slowFrames, 0) + } + func testRejectNaNAndBackwardsWithoutInflatingStats() { + var s = VeilRenderFrameStats() + s.observe(5) + s.observe(.nan); s.observe(.infinity); s.observe(-3) + s.observe(4) + s.observe(4.02) + XCTAssertEqual(s.report.sampleCount, 1) + } + func testSlowFramesCountWithinRing() { + var s = VeilRenderFrameStats() + s.observe(0) + s.observe(0.05) + s.observe(0.11) + XCTAssertEqual(s.report.slowFrames, 2) + XCTAssertEqual(s.report.sampleCount, 2) + } + func testRingBufferHardCapAndRecentSlowCount() { + var s = VeilRenderFrameStats() + s.observe(0) + s.observe(0.07) + for i in 1...400 { s.observe(0.07 + Double(i)*0.02) } + XCTAssertEqual(s.report.sampleCount, 240) + XCTAssertEqual(s.report.slowFrames, 0) + XCTAssertLessThan(s.report.elapsedSeconds, 5) + } + func testResetClearsCounters() { + var s = VeilRenderFrameStats() + s.observe(1);s.observe(1.05);s.reset() + XCTAssertEqual(s.report.sampleCount, 0) + s.observe(999) + XCTAssertEqual(s.report.sampleCount, 0) + } + func testDeterminismUnderIdenticalTraces() { + var a = VeilRenderFrameStats(), b = VeilRenderFrameStats() + for i in 0..<270 { + let t = Double(i)/30.0 + (i%9==0 ? 0.003 : 0) + a.observe(t);b.observe(t) + } + XCTAssertEqual(a.report,b.report) + } +} diff --git a/VeilLinkTests/VeilR7RenderCacheTests.swift b/VeilLinkTests/VeilR7RenderCacheTests.swift new file mode 100644 index 00000000..0dff0255 --- /dev/null +++ b/VeilLinkTests/VeilR7RenderCacheTests.swift @@ -0,0 +1,70 @@ +import XCTest +#if canImport(VeilMegaEngines) +@testable import VeilMegaEngines +#elseif canImport(VeilLink) +@testable import VeilLink +#endif + +final class VeilR7RenderCacheTests: XCTestCase { + typealias Cache = VeilRhythmRenderCache + typealias Watch = VeilRenderSnapshotWatchdog + + func testAllDistrictsCanInitialize() { + for d in 0..<4 { XCTAssertNotNil(Cache(seed: 42, district: d)) } + XCTAssertNil(Cache(seed: 42, district: -1)) + XCTAssertNil(Cache(seed: 42, district: 4)) + } + func testCacheMatchesReferenceForOneHundredSeedsAllDistricts() throws { + for seed in UInt64(0)..<100 { + for district in 0..<4 { + let cache = try XCTUnwrap(Cache(seed: seed, district: district)) + for beat in 0..<16 { + for remaining in [-0.18, 0.0, 0.075, 0.5, 1.1, 1.6, 2.0] { + let base = VeilRhythmVisualTimeline.notes(seed: seed, district: district, + nextBeat: beat, secondsToNextBeat: remaining, capacity: 5) + XCTAssertEqual(cache.visible(nextBeat: beat, secondsToNextBeat: remaining, capacity: 5), base) + } + } + } + } + } + func testInvalidCapacityBeatAndClockProduceEmpty() throws { + let c = try XCTUnwrap(Cache(seed: 1, district: 3)) + for k in [0, 17, -1] { XCTAssertTrue(c.visible(nextBeat: 2, secondsToNextBeat: 1, capacity:k).isEmpty) } + XCTAssertTrue(c.visible(nextBeat: -1, secondsToNextBeat: 0).isEmpty) + XCTAssertTrue(c.visible(nextBeat: 16, secondsToNextBeat: 0).isEmpty) + XCTAssertTrue(c.visible(nextBeat: 0, secondsToNextBeat: .infinity).isEmpty) + XCTAssertTrue(c.visible(nextBeat: 0, secondsToNextBeat: .nan).isEmpty) + } + func testStableVisualsIndifferentToRenderRate() throws { + let c = try XCTUnwrap(Cache(seed: 100, district: 2)) + XCTAssertEqual(c.visible(nextBeat: 1, secondsToNextBeat: 0.25), + c.visible(nextBeat: 1, secondsToNextBeat: 0.25)) + } + func testSnapshotFreshProgression() { + if case .fresh(let age) = Watch.check(now: 50.1, sampledAt: 50, running: true) { + XCTAssertEqual(age, 0.1, accuracy: 1e-10) + } else { XCTFail("expected fresh snapshot") } + XCTAssertEqual(Watch.check(now: 50, sampledAt: 50, running: true), .fresh(0)) + } + func testExpiredRunningSnapshotHidden() { + XCTAssertEqual(Watch.check(now: 51, sampledAt: 50, running: true), .stale) + XCTAssertEqual(Watch.check(now: 51, sampledAt: 50, running: false), .fresh(0)) + } + func testClockSkewPolicy() { + XCTAssertEqual(Watch.check(now: 10, sampledAt: 10.01, running: true), .fresh(0)) + XCTAssertEqual(Watch.check(now: 10, sampledAt: 10.2, running: true), .invalid) + } + func testInvalidInputsFailClosed() { + for v in [Double.nan, .infinity, -.infinity, -1] { + XCTAssertEqual(Watch.check(now: v, sampledAt: 10, running: true), .invalid) + XCTAssertEqual(Watch.check(now: 10, sampledAt: v, running: true), .invalid) + } + } + func testFrameClockStallPolicyAtBoundary() { + if case .fresh(let age) = Watch.check(now: 12 + Watch.maximumRunningAge, + sampledAt: 12, running: true) { + XCTAssertEqual(age, Watch.maximumRunningAge, accuracy: 1e-10) + } else { XCTFail("boundary snapshot must still be visible") } + } +} diff --git a/VeilLinkTests/VeilRareLabEngineTests.swift b/VeilLinkTests/VeilRareLabEngineTests.swift new file mode 100644 index 00000000..4205a787 --- /dev/null +++ b/VeilLinkTests/VeilRareLabEngineTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import VeilLink + +final class VeilRareLabEngineTests: XCTestCase { + func testFortyUniqueOperatorsAndAllSampleRuns() throws { + XCTAssertEqual(VeilRareLab.catalog.count, 40) + XCTAssertEqual(Set(VeilRareLab.catalog.map(\.id)).count, 40) + for item in VeilRareLab.catalog { + let output = try VeilRareLab.run(id: item.id, json: VeilRareLab.sample(for: item.id)) + XCTAssertFalse(output.isEmpty, "Missing sample output for \(item.id)") + } + } + func testTopologyHasCriticalArticulationAndBridge() throws { + let raw = #"{"edges":[[0,1],[1,2],[2,3],[0,4],[4,2]]}"# + XCTAssertTrue(try VeilRareLab.run(id: 8, json: raw).contains("2")) + XCTAssertTrue(try VeilRareLab.run(id: 9, json: raw).contains("2-3")) + } + func testCanDetectLossBurstsAndAcknowledgementHoles() throws { + let raw = #"{"expected":[0,1,2,3,4,5,6],"received":[0,3,6],"acks":[10,11,13]}"# + XCTAssertTrue(try VeilRareLab.run(id: 11, json: raw).contains("2 块")) + XCTAssertTrue(try VeilRareLab.run(id: 12, json: raw).contains("1 个")) + } + func testSignerChangeDoesNotRebuildKeys() throws { + let raw = #"{"oldGroup":"TEAM_A.shared","newGroup":"TEAM_B.shared"}"# + let output = try VeilRareLab.run(id: 23, json: raw) + XCTAssertTrue(output.contains("变更")) + XCTAssertTrue(output.contains("不能据此重建数据库密钥")) + } + func testTouchCoverageDiagnosticsAreAdvisory() throws { + let raw = #"{"phases":["down","move","cancel","down","up"],"touchGrid":[[8,8],[0,2]],"overlays":[{"decorative":true,"visible":true,"hittable":true}]}"# + XCTAssertTrue(try VeilRareLab.run(id: 32, json: raw).contains("50.0%")) + XCTAssertTrue(try VeilRareLab.run(id: 33, json: raw).contains("1,0")) + XCTAssertTrue(try VeilRareLab.run(id: 34, json: raw).contains("1 层")) + } + func testRejectsMaliciousAndOversizedPayloads() { + XCTAssertThrowsError(try VeilRareLab.run(id: 40, json: "{bad")) + XCTAssertThrowsError(try VeilRareLab.run(id: 41, json: "{}")) + XCTAssertThrowsError(try VeilRareLab.run(id: 1, json: String(repeating: "x", count: 64_001))) + XCTAssertThrowsError(try VeilRareLab.run(id: 8, json: #"{"edges":[[1]]}"#)) + } + func testHysteresisAndFiniteRateAnalysis() throws { + XCTAssertTrue(try VeilRareLab.run(id: 7, json: VeilRareLab.sample(for: 7)).contains("状态变更")) + XCTAssertThrowsError(try VeilRareLab.run(id: 7, json: #"{"rssi":[-70],"thresholdIn":-90,"thresholdOut":-60}"#)) + XCTAssertTrue(try VeilRareLab.run(id: 17, json: VeilRareLab.sample(for: 17)).contains("吞吐")) + } + func testAllSimulationsAreDeterministic() throws { + for item in VeilRareLab.catalog { + let sample = VeilRareLab.sample(for: item.id) + XCTAssertEqual(try VeilRareLab.run(id: item.id, json: sample), try VeilRareLab.run(id: item.id, json: sample)) + } + } +} diff --git a/VeilLinkTests/VeilRhythmExpeditionTests.swift b/VeilLinkTests/VeilRhythmExpeditionTests.swift new file mode 100644 index 00000000..70fe8897 --- /dev/null +++ b/VeilLinkTests/VeilRhythmExpeditionTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import VeilMegaEngines + +final class VeilRhythmExpeditionTests: XCTestCase { + typealias E = VeilRhythmExpedition + private func complete(_ session: inout E.Session) throws { + for beat in 0..= 40 { try g.apply(.undertake(.restore)) } + else if g.trust < 35 && g.funds >= 25 { try g.apply(.undertake(.outreach)) } + else if g.funds >= 80 { try g.apply(.undertake(.platform)) } + try g.apply(.dispatch) + if g.phase == .running { try g.apply(.closeDay) } + } + XCTAssertGreaterThanOrEqual(g.funds,0) + XCTAssertEqual(g.phase,.completed) + } + func testEraTransitionsAtDayFive() throws { + var g=E.Game(seed:4) + for _ in 0..<5 { try g.apply(.dispatch); try g.apply(.closeDay) } + XCTAssertEqual(g.day,5) + XCTAssertEqual(g.currentEra,.electrification) + } + func testCompleteTwentyDays() throws { + var g=E.Game(seed:6) + for _ in 0..<20 { + if g.condition < 30 && g.funds >= 40 { try g.apply(.undertake(.restore)) } + else if g.trust < 35 && g.funds >= 25 { try g.apply(.undertake(.outreach)) } + else if g.funds >= 25 { try g.apply(.undertake(.archive)) } + try g.apply(.dispatch) + if g.phase == .running { try g.apply(.closeDay) } + } + XCTAssertEqual(g.phase,.completed) + XCTAssertEqual(g.progress,1) + XCTAssertEqual(g.day,20) + XCTAssertTrue(g.historyPoints >= 4) + XCTAssertThrowsError(try g.apply(.dispatch)) + } + func testFailedActionIsAtomic() throws { + var g=E.Game(seed:1) + let before=try JSONEncoder().encode(g) + XCTAssertThrowsError(try g.apply(.closeDay)) + let after=try JSONEncoder().encode(g) + let pre=try JSONDecoder().decode(E.Game.self,from:before) + let post=try JSONDecoder().decode(E.Game.self,from:after) + XCTAssertEqual(pre.events,post.events) + XCTAssertEqual(g.funds,240) + } + func testReplayRoundTrip() throws { + var g=E.Game(seed:123) + try g.apply(.undertake(.archive)) + try g.apply(.dispatch) + try g.apply(.closeDay) + let imported=try E.importReplay(g.exportReplay()) + XCTAssertEqual(imported.commands,g.commands) + XCTAssertEqual(imported.events,g.events) + XCTAssertEqual(imported.funds,g.funds) + } + func testRejectInvalidReplay() { + XCTAssertThrowsError(try E.importReplay(Data("{}".utf8))) + XCTAssertThrowsError(try E.importReplay(Data(repeating:65,count:32769))) + XCTAssertThrowsError(try E.importReplay(Data("{\"version\":2,\"seed\":1,\"commands\":[]}".utf8))) + } + func testCapacityCappedAndStationConditionBounded() throws { + var g=E.Game(seed:8) + for _ in 0..<20 { + if g.funds >= 80 { try g.apply(.undertake(.platform)) } + try g.apply(.dispatch) + if g.phase == .running { try g.apply(.closeDay) } else { break } + } + XCTAssertLessThanOrEqual(g.capacity,140) + XCTAssertTrue((0...100).contains(g.condition)) + } + func testTwentyOriginalDayStories() { + XCTAssertEqual(E.dayStories.count,20) + XCTAssertEqual(Set(E.dayStories).count,20) + XCTAssertTrue(E.dayStories.allSatisfy { $0.count >= 18 }) + } + func testOriginalBulletinsHaveSpeaker() { + let b=E.Game(seed:1).bulletin + XCTAssertFalse(b.speaker.isEmpty) + XCTAssertFalse(b.message.isEmpty) + } + func testTwoHundredSeedsConservativeFullCampaign() throws { + for seed in UInt64(0)..<200 { + var g=E.Game(seed:seed) + for _ in 0..<20 { + if g.condition < 30 && g.funds >= 40 { try g.apply(.undertake(.restore)) } + else if g.trust < 35 && g.funds >= 25 { try g.apply(.undertake(.outreach)) } + else if g.funds >= 25 { try g.apply(.undertake(.archive)) } + try g.apply(.dispatch) + XCTAssertGreaterThanOrEqual(g.funds,0) + XCTAssertTrue((0...100).contains(g.trust)) + if g.phase == .running { try g.apply(.closeDay) } + } + XCTAssertEqual(g.phase,.completed,"seed=\(seed)") + XCTAssertEqual(g.day,20) + let loaded=try E.importReplay(g.exportReplay()) + XCTAssertEqual(loaded.funds,g.funds) + XCTAssertEqual(loaded.events,g.events) + } + } + +} diff --git a/VeilLinkTests/VeilStoryEngineTests.swift b/VeilLinkTests/VeilStoryEngineTests.swift new file mode 100644 index 00000000..02b50d7a --- /dev/null +++ b/VeilLinkTests/VeilStoryEngineTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import VeilLink + +final class VeilStoryEngineTests: XCTestCase { + func testAllDemoBooksValidate() throws { + XCTAssertEqual(VeilStoryLibrary.books.count, 3) + for book in VeilStoryLibrary.books { try book.validate() } + } + + func testNightTrainHiddenEnding() throws { + let book = VeilStoryLibrary.nightTrain + var run = try VeilStoryRun(book: book) + try run.choose("ticket", in: book) + try run.choose("envelope", in: book) + try run.choose("follow", in: book) + XCTAssertEqual(run.sceneID, "observatory") + XCTAssertEqual(run.score, 25) + XCTAssertEqual(run.items, ["ticket"]) + XCTAssertEqual(book.scene(id: run.sceneID)?.ending, "隐藏结局:夜空的坐标") + } + + func testNightTrainOrdinaryEnding() throws { + let book = VeilStoryLibrary.nightTrain + var run = try VeilStoryRun(book: book) + try run.choose("wait", in: book) + XCTAssertEqual(run.sceneID, "morning") + XCTAssertThrowsError(try run.choose("wait", in: book)) { error in + XCTAssertEqual(error as? VeilStoryError, .storyFinished) + } + } + + func testInvalidChoiceIsAtomic() throws { + let book = VeilStoryLibrary.nightTrain + var run = try VeilStoryRun(book: book) + let before = run + XCTAssertThrowsError(try run.choose("does-not-exist", in: book)) + XCTAssertEqual(run, before) + } + + func testReplayDeterministic() throws { + let book = VeilStoryLibrary.lighthouse + var run = try VeilStoryRun(book: book) + try run.choose("workshop", in: book) + try run.choose("climb", in: book) + XCTAssertEqual(try VeilStoryRun.replay(book: book, decisions: run.history), run) + } + + func testReplayRejectsTampering() throws { + let book = VeilStoryLibrary.lighthouse + let forged = VeilStoryDecision(fromScene: "dock", choiceID: "workshop", toScene: "quiet") + XCTAssertThrowsError(try VeilStoryRun.replay(book: book, decisions: [forged])) + } + + func testJSONRoundTrip() throws { + let book = VeilStoryLibrary.observatory + let data = try JSONEncoder().encode(book) + let decoded = try JSONDecoder().decode(VeilStoryBook.self, from: data) + try decoded.validate() + XCTAssertEqual(book, decoded) + } + + func testBrokenLinkRejected() throws { + var book = VeilStoryLibrary.lighthouse + book = VeilStoryBook(id: book.id, title: book.title, description: book.description, + startingScene: "unknown", scenes: book.scenes) + XCTAssertThrowsError(try book.validate()) + } + + func testScoreCappedAndRequiredItems() throws { + let book = VeilStoryLibrary.lighthouse + var run = try VeilStoryRun(book: book) + XCTAssertEqual(run.availableChoices(in: book).count, 2) + try run.choose("workshop", in: book) + XCTAssertTrue(run.items.contains("lamp")) + try run.choose("climb", in: book) + XCTAssertEqual(run.sceneID, "tower") + } + + func testFiniteTurnGuard() throws { + let book = VeilStoryLibrary.observatory + var run = try VeilStoryRun(book: book) + try run.choose("repair", in: book) + for _ in 0..<63 { + try run.choose("explore", in: book) + try run.choose("backup", in: book) + } + try run.choose("explore", in: book) + XCTAssertEqual(run.history.count, VeilStoryRun.maximumTurns) + XCTAssertThrowsError(try run.choose("backup", in: book)) { error in + XCTAssertEqual(error as? VeilStoryError, .turnLimitReached) + } + } +} diff --git a/docs/RELEASE_27_0_BUILD_62.md b/docs/RELEASE_27_0_BUILD_62.md new file mode 100644 index 00000000..8035d5d3 --- /dev/null +++ b/docs/RELEASE_27_0_BUILD_62.md @@ -0,0 +1,31 @@ +# VeilLink 27.0 · Build 62 — Inbox Convergence + +VeilLink 27.0 is a major integration release built from the reviewed, source-complete Inbox proposals. Duplicate generations, catalog-only claims and already-integrated historical patches were explicitly dispositioned rather than reapplied. + +## Highlights + +- New V27 Feature Library with indexed search, lazy paging, conflict auditing and adaptive rendering. +- Mission Control, RareLab, Productivity Lab, Emotion Garden and AetherLab local tools. +- Branching Story Lab, Lattice Clash and the R7 native real-time rhythm renderer. +- Offline camera QR reading and pairing input validation through pinned CodeScanner 2.5.2. +- Offline HTML reading through pinned SwiftSoup 2.9.6. +- Read-only backup ZIP preflight using the existing ZIPFoundation integration. +- P0 touch reliability work for activation, PTT, tactical boards and real-time games. +- Passive raw-touch telemetry is opt-in and dynamically removed when disabled. +- Startup black-box diagnostics and corrected A9 stress assertions. +- Owner God Mode retains high-quality transient animation behavior. + +## Compatibility and privacy + +- Minimum iOS version remains iOS 15. +- QR and HTML tools are user-initiated. Scanned text is validated before use; HTML is parsed locally without automatic URL loading. +- Backup preflight does not extract archives or modify the database, keys or identity. +- Wire v4, SQLite v10, identity and Keychain contracts are unchanged. + +## Open hardware evidence + +GitHub macOS CI provides Apple SDK builds and XCTest evidence. Physical iPhone 7 consent, keyboard, camera, BLE/LAN, frame pacing and thermal checks remain device-test items and must not be inferred from simulator success. + +## Artifact + +The release IPA is unsigned. Installation requires user-side signing or an appropriate compatible installation environment. diff --git a/docs/THIRD_PARTY_NOTICES_V27.md b/docs/THIRD_PARTY_NOTICES_V27.md new file mode 100644 index 00000000..f3b3b1c0 --- /dev/null +++ b/docs/THIRD_PARTY_NOTICES_V27.md @@ -0,0 +1,11 @@ +# VeilLink 27.0 third-party notices + +VeilLink 27.0 resolves these Swift Package Manager dependencies from their pinned project configuration. No upstream source is copied into this repository by the V27 integration. + +| Package | Version | License | Upstream | +|---|---:|---|---| +| CodeScanner | 2.5.2 (exact) | MIT | https://github.com/twostraws/CodeScanner | +| SwiftSoup | 2.9.6 (exact) | MIT | https://github.com/scinfu/SwiftSoup | +| ZIPFoundation | from 0.9.19 | MIT | https://github.com/weichsel/ZIPFoundation | + +The distributed application bundle must retain the license metadata produced by Swift Package Manager. Full license texts remain available from each pinned upstream tag and its resolved checkout. diff --git a/docs/V27_INBOX_INTEGRATION_MATRIX.md b/docs/V27_INBOX_INTEGRATION_MATRIX.md new file mode 100644 index 00000000..409fe4a1 --- /dev/null +++ b/docs/V27_INBOX_INTEGRATION_MATRIX.md @@ -0,0 +1,40 @@ +# V27.0 Inbox integration matrix + +Base: `501ef814a0a9bea41419fe2d9f48315371b30320` + +## Integrated selections + +| PR | Selection | +|---:|---| +| #64 | Root particle gesture removal, corrected stress assertion, startup black-box/export diagnostics. Its God Mode animation downgrade was rejected. | +| #66 | Opt-in/dynamically removable passive touch recorder. | +| #65 | Per-file arbitration: reliability activation/board/game gestures plus audit PTT lifecycle and decorative hit-testing. Competing full patches were not stacked. | +| #68 | Source-complete Mission Control implementation; replaces contract-only #67. | +| #70 | RareLab engine, UI and tests. | +| #72 | Productivity engine, UI and tests. | +| #74 | Indexed 1,000-entry Emotion Garden runtime, UI and tests. | +| #76 | AetherLab engines, UI and tests. | +| #71 | E01 Story Lab source only; 89 catalog rows are not represented as runnable features. | +| #78 | Entertainment feature gate and tests only. | +| #79/#86 | R7 combined candidate: E191/E192 engines, native renderer and latest tests. R6 duplicate paths were not stacked. | +| #21 | Lattice Clash engine, UI and tests. | +| #84 | Preferred CodeScanner 2.5.2 camera QR implementation; duplicate #81 excluded. | +| #88 | SwiftSoup 2.9.6 offline HTML reader. | +| #89 | ZIPFoundation backup preflight and guarded restore entry. | +| #90 | Shared feature registry, indexed discovery, paging, conflict audit and adaptive render policy. | + +## Processed without product application + +- #9, #10: historical direct-product evidence; not replayed over current main. +- #18–#20, #25–#34, #49–#60: already absorbed or evolved in Build 58–61; replay would risk rollback or duplicate symbols. +- #35: deferred because its Rainline patch has a known invalid hunk and overlaps current game paths. +- #67: superseded by source-complete #68. +- #69: planning catalog only. +- #73/#75: external-package contracts without complete GitHub source. +- #80/#85: vendor payload missing; dependent wrapper remains deferred. +- #81: duplicate of selected #84. +- #83: superseded by selected R7 #86. + +## Protected surfaces + +Wire v4, SQLite v10, Keychain/identity, signing secrets and A9/A10 governance contracts were not changed. Release identity moved to 27.0 (Build 62) under the Owner's V27 live-lane grant. diff --git a/docs/features/VEILLINK_EMOTION_VALUE_1000_V1.md b/docs/features/VEILLINK_EMOTION_VALUE_1000_V1.md new file mode 100644 index 00000000..88913236 --- /dev/null +++ b/docs/features/VEILLINK_EMOTION_VALUE_1000_V1.md @@ -0,0 +1,1015 @@ +# VeilLink Emotion Value 1000 — Runtime Capability Catalog v2 + +> 本目录与 `VeilEmotionGardenEngine.rituals` 一一对应:10 种状态 × 10 种互动 × 10 个场景 = 1000 个运行时可调用条目。每项都有稳定 ID、独立提示、独立三步操作、完成回响、持续时间、图标、筛选/搜索路径和完成动作;仍须经过 iOS 构建与真机验证后才可称为已上线。 + +| ID | 状态 | 互动 | 场景 | 运行时键 | 入口 | 状态 | +|---|---|---|---|---|---|---| +| EV-0001 | 平静 | 一分钟觉察 | 晨间 | calm/notice/morning | 情绪花园 → 平静 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0002 | 平静 | 一分钟觉察 | 午间 | calm/notice/midday | 情绪花园 → 平静 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0003 | 平静 | 一分钟觉察 | 夜间 | calm/notice/night | 情绪花园 → 平静 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0004 | 平静 | 一分钟觉察 | 独处 | calm/notice/alone | 情绪花园 → 平静 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0005 | 平静 | 一分钟觉察 | 通勤 | calm/notice/commute | 情绪花园 → 平静 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0006 | 平静 | 一分钟觉察 | 工作间隙 | calm/notice/workBreak | 情绪花园 → 平静 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0007 | 平静 | 一分钟觉察 | 完成之后 | calm/notice/afterCompletion | 情绪花园 → 平静 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0008 | 平静 | 一分钟觉察 | 等待中 | calm/notice/waiting | 情绪花园 → 平静 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0009 | 平静 | 一分钟觉察 | 雨天 | calm/notice/rainyDay | 情绪花园 → 平静 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0010 | 平静 | 一分钟觉察 | 特别日子 | calm/notice/specialDay | 情绪花园 → 平静 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0011 | 平静 | 呼吸节拍 | 晨间 | calm/breath/morning | 情绪花园 → 平静 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0012 | 平静 | 呼吸节拍 | 午间 | calm/breath/midday | 情绪花园 → 平静 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0013 | 平静 | 呼吸节拍 | 夜间 | calm/breath/night | 情绪花园 → 平静 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0014 | 平静 | 呼吸节拍 | 独处 | calm/breath/alone | 情绪花园 → 平静 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0015 | 平静 | 呼吸节拍 | 通勤 | calm/breath/commute | 情绪花园 → 平静 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0016 | 平静 | 呼吸节拍 | 工作间隙 | calm/breath/workBreak | 情绪花园 → 平静 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0017 | 平静 | 呼吸节拍 | 完成之后 | calm/breath/afterCompletion | 情绪花园 → 平静 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0018 | 平静 | 呼吸节拍 | 等待中 | calm/breath/waiting | 情绪花园 → 平静 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0019 | 平静 | 呼吸节拍 | 雨天 | calm/breath/rainyDay | 情绪花园 → 平静 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0020 | 平静 | 呼吸节拍 | 特别日子 | calm/breath/specialDay | 情绪花园 → 平静 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0021 | 平静 | 给感受命名 | 晨间 | calm/name/morning | 情绪花园 → 平静 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0022 | 平静 | 给感受命名 | 午间 | calm/name/midday | 情绪花园 → 平静 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0023 | 平静 | 给感受命名 | 夜间 | calm/name/night | 情绪花园 → 平静 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0024 | 平静 | 给感受命名 | 独处 | calm/name/alone | 情绪花园 → 平静 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0025 | 平静 | 给感受命名 | 通勤 | calm/name/commute | 情绪花园 → 平静 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0026 | 平静 | 给感受命名 | 工作间隙 | calm/name/workBreak | 情绪花园 → 平静 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0027 | 平静 | 给感受命名 | 完成之后 | calm/name/afterCompletion | 情绪花园 → 平静 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0028 | 平静 | 给感受命名 | 等待中 | calm/name/waiting | 情绪花园 → 平静 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0029 | 平静 | 给感受命名 | 雨天 | calm/name/rainyDay | 情绪花园 → 平静 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0030 | 平静 | 给感受命名 | 特别日子 | calm/name/specialDay | 情绪花园 → 平静 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0031 | 平静 | 给自己一句话 | 晨间 | calm/kindness/morning | 情绪花园 → 平静 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0032 | 平静 | 给自己一句话 | 午间 | calm/kindness/midday | 情绪花园 → 平静 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0033 | 平静 | 给自己一句话 | 夜间 | calm/kindness/night | 情绪花园 → 平静 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0034 | 平静 | 给自己一句话 | 独处 | calm/kindness/alone | 情绪花园 → 平静 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0035 | 平静 | 给自己一句话 | 通勤 | calm/kindness/commute | 情绪花园 → 平静 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0036 | 平静 | 给自己一句话 | 工作间隙 | calm/kindness/workBreak | 情绪花园 → 平静 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0037 | 平静 | 给自己一句话 | 完成之后 | calm/kindness/afterCompletion | 情绪花园 → 平静 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0038 | 平静 | 给自己一句话 | 等待中 | calm/kindness/waiting | 情绪花园 → 平静 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0039 | 平静 | 给自己一句话 | 雨天 | calm/kindness/rainyDay | 情绪花园 → 平静 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0040 | 平静 | 给自己一句话 | 特别日子 | calm/kindness/specialDay | 情绪花园 → 平静 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0041 | 平静 | 感官锚点 | 晨间 | calm/anchor/morning | 情绪花园 → 平静 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0042 | 平静 | 感官锚点 | 午间 | calm/anchor/midday | 情绪花园 → 平静 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0043 | 平静 | 感官锚点 | 夜间 | calm/anchor/night | 情绪花园 → 平静 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0044 | 平静 | 感官锚点 | 独处 | calm/anchor/alone | 情绪花园 → 平静 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0045 | 平静 | 感官锚点 | 通勤 | calm/anchor/commute | 情绪花园 → 平静 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0046 | 平静 | 感官锚点 | 工作间隙 | calm/anchor/workBreak | 情绪花园 → 平静 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0047 | 平静 | 感官锚点 | 完成之后 | calm/anchor/afterCompletion | 情绪花园 → 平静 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0048 | 平静 | 感官锚点 | 等待中 | calm/anchor/waiting | 情绪花园 → 平静 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0049 | 平静 | 感官锚点 | 雨天 | calm/anchor/rainyDay | 情绪花园 → 平静 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0050 | 平静 | 感官锚点 | 特别日子 | calm/anchor/specialDay | 情绪花园 → 平静 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0051 | 平静 | 微小感谢 | 晨间 | calm/gratitude/morning | 情绪花园 → 平静 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0052 | 平静 | 微小感谢 | 午间 | calm/gratitude/midday | 情绪花园 → 平静 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0053 | 平静 | 微小感谢 | 夜间 | calm/gratitude/night | 情绪花园 → 平静 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0054 | 平静 | 微小感谢 | 独处 | calm/gratitude/alone | 情绪花园 → 平静 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0055 | 平静 | 微小感谢 | 通勤 | calm/gratitude/commute | 情绪花园 → 平静 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0056 | 平静 | 微小感谢 | 工作间隙 | calm/gratitude/workBreak | 情绪花园 → 平静 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0057 | 平静 | 微小感谢 | 完成之后 | calm/gratitude/afterCompletion | 情绪花园 → 平静 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0058 | 平静 | 微小感谢 | 等待中 | calm/gratitude/waiting | 情绪花园 → 平静 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0059 | 平静 | 微小感谢 | 雨天 | calm/gratitude/rainyDay | 情绪花园 → 平静 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0060 | 平静 | 微小感谢 | 特别日子 | calm/gratitude/specialDay | 情绪花园 → 平静 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0061 | 平静 | 收集小胜利 | 晨间 | calm/smallWin/morning | 情绪花园 → 平静 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0062 | 平静 | 收集小胜利 | 午间 | calm/smallWin/midday | 情绪花园 → 平静 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0063 | 平静 | 收集小胜利 | 夜间 | calm/smallWin/night | 情绪花园 → 平静 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0064 | 平静 | 收集小胜利 | 独处 | calm/smallWin/alone | 情绪花园 → 平静 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0065 | 平静 | 收集小胜利 | 通勤 | calm/smallWin/commute | 情绪花园 → 平静 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0066 | 平静 | 收集小胜利 | 工作间隙 | calm/smallWin/workBreak | 情绪花园 → 平静 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0067 | 平静 | 收集小胜利 | 完成之后 | calm/smallWin/afterCompletion | 情绪花园 → 平静 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0068 | 平静 | 收集小胜利 | 等待中 | calm/smallWin/waiting | 情绪花园 → 平静 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0069 | 平静 | 收集小胜利 | 雨天 | calm/smallWin/rainyDay | 情绪花园 → 平静 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0070 | 平静 | 收集小胜利 | 特别日子 | calm/smallWin/specialDay | 情绪花园 → 平静 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0071 | 平静 | 轻连接 | 晨间 | calm/connection/morning | 情绪花园 → 平静 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0072 | 平静 | 轻连接 | 午间 | calm/connection/midday | 情绪花园 → 平静 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0073 | 平静 | 轻连接 | 夜间 | calm/connection/night | 情绪花园 → 平静 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0074 | 平静 | 轻连接 | 独处 | calm/connection/alone | 情绪花园 → 平静 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0075 | 平静 | 轻连接 | 通勤 | calm/connection/commute | 情绪花园 → 平静 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0076 | 平静 | 轻连接 | 工作间隙 | calm/connection/workBreak | 情绪花园 → 平静 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0077 | 平静 | 轻连接 | 完成之后 | calm/connection/afterCompletion | 情绪花园 → 平静 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0078 | 平静 | 轻连接 | 等待中 | calm/connection/waiting | 情绪花园 → 平静 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0079 | 平静 | 轻连接 | 雨天 | calm/connection/rainyDay | 情绪花园 → 平静 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0080 | 平静 | 轻连接 | 特别日子 | calm/connection/specialDay | 情绪花园 → 平静 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0081 | 平静 | 温和重启 | 晨间 | calm/reset/morning | 情绪花园 → 平静 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0082 | 平静 | 温和重启 | 午间 | calm/reset/midday | 情绪花园 → 平静 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0083 | 平静 | 温和重启 | 夜间 | calm/reset/night | 情绪花园 → 平静 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0084 | 平静 | 温和重启 | 独处 | calm/reset/alone | 情绪花园 → 平静 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0085 | 平静 | 温和重启 | 通勤 | calm/reset/commute | 情绪花园 → 平静 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0086 | 平静 | 温和重启 | 工作间隙 | calm/reset/workBreak | 情绪花园 → 平静 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0087 | 平静 | 温和重启 | 完成之后 | calm/reset/afterCompletion | 情绪花园 → 平静 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0088 | 平静 | 温和重启 | 等待中 | calm/reset/waiting | 情绪花园 → 平静 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0089 | 平静 | 温和重启 | 雨天 | calm/reset/rainyDay | 情绪花园 → 平静 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0090 | 平静 | 温和重启 | 特别日子 | calm/reset/specialDay | 情绪花园 → 平静 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0091 | 平静 | 一点玩心 | 晨间 | calm/play/morning | 情绪花园 → 平静 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0092 | 平静 | 一点玩心 | 午间 | calm/play/midday | 情绪花园 → 平静 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0093 | 平静 | 一点玩心 | 夜间 | calm/play/night | 情绪花园 → 平静 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0094 | 平静 | 一点玩心 | 独处 | calm/play/alone | 情绪花园 → 平静 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0095 | 平静 | 一点玩心 | 通勤 | calm/play/commute | 情绪花园 → 平静 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0096 | 平静 | 一点玩心 | 工作间隙 | calm/play/workBreak | 情绪花园 → 平静 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0097 | 平静 | 一点玩心 | 完成之后 | calm/play/afterCompletion | 情绪花园 → 平静 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0098 | 平静 | 一点玩心 | 等待中 | calm/play/waiting | 情绪花园 → 平静 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0099 | 平静 | 一点玩心 | 雨天 | calm/play/rainyDay | 情绪花园 → 平静 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0100 | 平静 | 一点玩心 | 特别日子 | calm/play/specialDay | 情绪花园 → 平静 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0101 | 低落 | 一分钟觉察 | 晨间 | low/notice/morning | 情绪花园 → 低落 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0102 | 低落 | 一分钟觉察 | 午间 | low/notice/midday | 情绪花园 → 低落 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0103 | 低落 | 一分钟觉察 | 夜间 | low/notice/night | 情绪花园 → 低落 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0104 | 低落 | 一分钟觉察 | 独处 | low/notice/alone | 情绪花园 → 低落 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0105 | 低落 | 一分钟觉察 | 通勤 | low/notice/commute | 情绪花园 → 低落 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0106 | 低落 | 一分钟觉察 | 工作间隙 | low/notice/workBreak | 情绪花园 → 低落 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0107 | 低落 | 一分钟觉察 | 完成之后 | low/notice/afterCompletion | 情绪花园 → 低落 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0108 | 低落 | 一分钟觉察 | 等待中 | low/notice/waiting | 情绪花园 → 低落 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0109 | 低落 | 一分钟觉察 | 雨天 | low/notice/rainyDay | 情绪花园 → 低落 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0110 | 低落 | 一分钟觉察 | 特别日子 | low/notice/specialDay | 情绪花园 → 低落 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0111 | 低落 | 呼吸节拍 | 晨间 | low/breath/morning | 情绪花园 → 低落 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0112 | 低落 | 呼吸节拍 | 午间 | low/breath/midday | 情绪花园 → 低落 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0113 | 低落 | 呼吸节拍 | 夜间 | low/breath/night | 情绪花园 → 低落 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0114 | 低落 | 呼吸节拍 | 独处 | low/breath/alone | 情绪花园 → 低落 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0115 | 低落 | 呼吸节拍 | 通勤 | low/breath/commute | 情绪花园 → 低落 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0116 | 低落 | 呼吸节拍 | 工作间隙 | low/breath/workBreak | 情绪花园 → 低落 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0117 | 低落 | 呼吸节拍 | 完成之后 | low/breath/afterCompletion | 情绪花园 → 低落 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0118 | 低落 | 呼吸节拍 | 等待中 | low/breath/waiting | 情绪花园 → 低落 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0119 | 低落 | 呼吸节拍 | 雨天 | low/breath/rainyDay | 情绪花园 → 低落 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0120 | 低落 | 呼吸节拍 | 特别日子 | low/breath/specialDay | 情绪花园 → 低落 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0121 | 低落 | 给感受命名 | 晨间 | low/name/morning | 情绪花园 → 低落 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0122 | 低落 | 给感受命名 | 午间 | low/name/midday | 情绪花园 → 低落 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0123 | 低落 | 给感受命名 | 夜间 | low/name/night | 情绪花园 → 低落 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0124 | 低落 | 给感受命名 | 独处 | low/name/alone | 情绪花园 → 低落 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0125 | 低落 | 给感受命名 | 通勤 | low/name/commute | 情绪花园 → 低落 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0126 | 低落 | 给感受命名 | 工作间隙 | low/name/workBreak | 情绪花园 → 低落 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0127 | 低落 | 给感受命名 | 完成之后 | low/name/afterCompletion | 情绪花园 → 低落 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0128 | 低落 | 给感受命名 | 等待中 | low/name/waiting | 情绪花园 → 低落 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0129 | 低落 | 给感受命名 | 雨天 | low/name/rainyDay | 情绪花园 → 低落 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0130 | 低落 | 给感受命名 | 特别日子 | low/name/specialDay | 情绪花园 → 低落 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0131 | 低落 | 给自己一句话 | 晨间 | low/kindness/morning | 情绪花园 → 低落 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0132 | 低落 | 给自己一句话 | 午间 | low/kindness/midday | 情绪花园 → 低落 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0133 | 低落 | 给自己一句话 | 夜间 | low/kindness/night | 情绪花园 → 低落 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0134 | 低落 | 给自己一句话 | 独处 | low/kindness/alone | 情绪花园 → 低落 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0135 | 低落 | 给自己一句话 | 通勤 | low/kindness/commute | 情绪花园 → 低落 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0136 | 低落 | 给自己一句话 | 工作间隙 | low/kindness/workBreak | 情绪花园 → 低落 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0137 | 低落 | 给自己一句话 | 完成之后 | low/kindness/afterCompletion | 情绪花园 → 低落 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0138 | 低落 | 给自己一句话 | 等待中 | low/kindness/waiting | 情绪花园 → 低落 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0139 | 低落 | 给自己一句话 | 雨天 | low/kindness/rainyDay | 情绪花园 → 低落 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0140 | 低落 | 给自己一句话 | 特别日子 | low/kindness/specialDay | 情绪花园 → 低落 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0141 | 低落 | 感官锚点 | 晨间 | low/anchor/morning | 情绪花园 → 低落 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0142 | 低落 | 感官锚点 | 午间 | low/anchor/midday | 情绪花园 → 低落 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0143 | 低落 | 感官锚点 | 夜间 | low/anchor/night | 情绪花园 → 低落 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0144 | 低落 | 感官锚点 | 独处 | low/anchor/alone | 情绪花园 → 低落 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0145 | 低落 | 感官锚点 | 通勤 | low/anchor/commute | 情绪花园 → 低落 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0146 | 低落 | 感官锚点 | 工作间隙 | low/anchor/workBreak | 情绪花园 → 低落 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0147 | 低落 | 感官锚点 | 完成之后 | low/anchor/afterCompletion | 情绪花园 → 低落 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0148 | 低落 | 感官锚点 | 等待中 | low/anchor/waiting | 情绪花园 → 低落 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0149 | 低落 | 感官锚点 | 雨天 | low/anchor/rainyDay | 情绪花园 → 低落 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0150 | 低落 | 感官锚点 | 特别日子 | low/anchor/specialDay | 情绪花园 → 低落 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0151 | 低落 | 微小感谢 | 晨间 | low/gratitude/morning | 情绪花园 → 低落 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0152 | 低落 | 微小感谢 | 午间 | low/gratitude/midday | 情绪花园 → 低落 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0153 | 低落 | 微小感谢 | 夜间 | low/gratitude/night | 情绪花园 → 低落 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0154 | 低落 | 微小感谢 | 独处 | low/gratitude/alone | 情绪花园 → 低落 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0155 | 低落 | 微小感谢 | 通勤 | low/gratitude/commute | 情绪花园 → 低落 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0156 | 低落 | 微小感谢 | 工作间隙 | low/gratitude/workBreak | 情绪花园 → 低落 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0157 | 低落 | 微小感谢 | 完成之后 | low/gratitude/afterCompletion | 情绪花园 → 低落 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0158 | 低落 | 微小感谢 | 等待中 | low/gratitude/waiting | 情绪花园 → 低落 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0159 | 低落 | 微小感谢 | 雨天 | low/gratitude/rainyDay | 情绪花园 → 低落 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0160 | 低落 | 微小感谢 | 特别日子 | low/gratitude/specialDay | 情绪花园 → 低落 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0161 | 低落 | 收集小胜利 | 晨间 | low/smallWin/morning | 情绪花园 → 低落 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0162 | 低落 | 收集小胜利 | 午间 | low/smallWin/midday | 情绪花园 → 低落 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0163 | 低落 | 收集小胜利 | 夜间 | low/smallWin/night | 情绪花园 → 低落 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0164 | 低落 | 收集小胜利 | 独处 | low/smallWin/alone | 情绪花园 → 低落 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0165 | 低落 | 收集小胜利 | 通勤 | low/smallWin/commute | 情绪花园 → 低落 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0166 | 低落 | 收集小胜利 | 工作间隙 | low/smallWin/workBreak | 情绪花园 → 低落 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0167 | 低落 | 收集小胜利 | 完成之后 | low/smallWin/afterCompletion | 情绪花园 → 低落 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0168 | 低落 | 收集小胜利 | 等待中 | low/smallWin/waiting | 情绪花园 → 低落 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0169 | 低落 | 收集小胜利 | 雨天 | low/smallWin/rainyDay | 情绪花园 → 低落 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0170 | 低落 | 收集小胜利 | 特别日子 | low/smallWin/specialDay | 情绪花园 → 低落 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0171 | 低落 | 轻连接 | 晨间 | low/connection/morning | 情绪花园 → 低落 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0172 | 低落 | 轻连接 | 午间 | low/connection/midday | 情绪花园 → 低落 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0173 | 低落 | 轻连接 | 夜间 | low/connection/night | 情绪花园 → 低落 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0174 | 低落 | 轻连接 | 独处 | low/connection/alone | 情绪花园 → 低落 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0175 | 低落 | 轻连接 | 通勤 | low/connection/commute | 情绪花园 → 低落 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0176 | 低落 | 轻连接 | 工作间隙 | low/connection/workBreak | 情绪花园 → 低落 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0177 | 低落 | 轻连接 | 完成之后 | low/connection/afterCompletion | 情绪花园 → 低落 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0178 | 低落 | 轻连接 | 等待中 | low/connection/waiting | 情绪花园 → 低落 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0179 | 低落 | 轻连接 | 雨天 | low/connection/rainyDay | 情绪花园 → 低落 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0180 | 低落 | 轻连接 | 特别日子 | low/connection/specialDay | 情绪花园 → 低落 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0181 | 低落 | 温和重启 | 晨间 | low/reset/morning | 情绪花园 → 低落 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0182 | 低落 | 温和重启 | 午间 | low/reset/midday | 情绪花园 → 低落 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0183 | 低落 | 温和重启 | 夜间 | low/reset/night | 情绪花园 → 低落 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0184 | 低落 | 温和重启 | 独处 | low/reset/alone | 情绪花园 → 低落 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0185 | 低落 | 温和重启 | 通勤 | low/reset/commute | 情绪花园 → 低落 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0186 | 低落 | 温和重启 | 工作间隙 | low/reset/workBreak | 情绪花园 → 低落 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0187 | 低落 | 温和重启 | 完成之后 | low/reset/afterCompletion | 情绪花园 → 低落 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0188 | 低落 | 温和重启 | 等待中 | low/reset/waiting | 情绪花园 → 低落 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0189 | 低落 | 温和重启 | 雨天 | low/reset/rainyDay | 情绪花园 → 低落 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0190 | 低落 | 温和重启 | 特别日子 | low/reset/specialDay | 情绪花园 → 低落 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0191 | 低落 | 一点玩心 | 晨间 | low/play/morning | 情绪花园 → 低落 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0192 | 低落 | 一点玩心 | 午间 | low/play/midday | 情绪花园 → 低落 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0193 | 低落 | 一点玩心 | 夜间 | low/play/night | 情绪花园 → 低落 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0194 | 低落 | 一点玩心 | 独处 | low/play/alone | 情绪花园 → 低落 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0195 | 低落 | 一点玩心 | 通勤 | low/play/commute | 情绪花园 → 低落 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0196 | 低落 | 一点玩心 | 工作间隙 | low/play/workBreak | 情绪花园 → 低落 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0197 | 低落 | 一点玩心 | 完成之后 | low/play/afterCompletion | 情绪花园 → 低落 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0198 | 低落 | 一点玩心 | 等待中 | low/play/waiting | 情绪花园 → 低落 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0199 | 低落 | 一点玩心 | 雨天 | low/play/rainyDay | 情绪花园 → 低落 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0200 | 低落 | 一点玩心 | 特别日子 | low/play/specialDay | 情绪花园 → 低落 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0201 | 紧绷 | 一分钟觉察 | 晨间 | tense/notice/morning | 情绪花园 → 紧绷 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0202 | 紧绷 | 一分钟觉察 | 午间 | tense/notice/midday | 情绪花园 → 紧绷 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0203 | 紧绷 | 一分钟觉察 | 夜间 | tense/notice/night | 情绪花园 → 紧绷 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0204 | 紧绷 | 一分钟觉察 | 独处 | tense/notice/alone | 情绪花园 → 紧绷 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0205 | 紧绷 | 一分钟觉察 | 通勤 | tense/notice/commute | 情绪花园 → 紧绷 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0206 | 紧绷 | 一分钟觉察 | 工作间隙 | tense/notice/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0207 | 紧绷 | 一分钟觉察 | 完成之后 | tense/notice/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0208 | 紧绷 | 一分钟觉察 | 等待中 | tense/notice/waiting | 情绪花园 → 紧绷 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0209 | 紧绷 | 一分钟觉察 | 雨天 | tense/notice/rainyDay | 情绪花园 → 紧绷 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0210 | 紧绷 | 一分钟觉察 | 特别日子 | tense/notice/specialDay | 情绪花园 → 紧绷 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0211 | 紧绷 | 呼吸节拍 | 晨间 | tense/breath/morning | 情绪花园 → 紧绷 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0212 | 紧绷 | 呼吸节拍 | 午间 | tense/breath/midday | 情绪花园 → 紧绷 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0213 | 紧绷 | 呼吸节拍 | 夜间 | tense/breath/night | 情绪花园 → 紧绷 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0214 | 紧绷 | 呼吸节拍 | 独处 | tense/breath/alone | 情绪花园 → 紧绷 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0215 | 紧绷 | 呼吸节拍 | 通勤 | tense/breath/commute | 情绪花园 → 紧绷 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0216 | 紧绷 | 呼吸节拍 | 工作间隙 | tense/breath/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0217 | 紧绷 | 呼吸节拍 | 完成之后 | tense/breath/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0218 | 紧绷 | 呼吸节拍 | 等待中 | tense/breath/waiting | 情绪花园 → 紧绷 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0219 | 紧绷 | 呼吸节拍 | 雨天 | tense/breath/rainyDay | 情绪花园 → 紧绷 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0220 | 紧绷 | 呼吸节拍 | 特别日子 | tense/breath/specialDay | 情绪花园 → 紧绷 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0221 | 紧绷 | 给感受命名 | 晨间 | tense/name/morning | 情绪花园 → 紧绷 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0222 | 紧绷 | 给感受命名 | 午间 | tense/name/midday | 情绪花园 → 紧绷 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0223 | 紧绷 | 给感受命名 | 夜间 | tense/name/night | 情绪花园 → 紧绷 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0224 | 紧绷 | 给感受命名 | 独处 | tense/name/alone | 情绪花园 → 紧绷 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0225 | 紧绷 | 给感受命名 | 通勤 | tense/name/commute | 情绪花园 → 紧绷 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0226 | 紧绷 | 给感受命名 | 工作间隙 | tense/name/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0227 | 紧绷 | 给感受命名 | 完成之后 | tense/name/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0228 | 紧绷 | 给感受命名 | 等待中 | tense/name/waiting | 情绪花园 → 紧绷 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0229 | 紧绷 | 给感受命名 | 雨天 | tense/name/rainyDay | 情绪花园 → 紧绷 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0230 | 紧绷 | 给感受命名 | 特别日子 | tense/name/specialDay | 情绪花园 → 紧绷 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0231 | 紧绷 | 给自己一句话 | 晨间 | tense/kindness/morning | 情绪花园 → 紧绷 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0232 | 紧绷 | 给自己一句话 | 午间 | tense/kindness/midday | 情绪花园 → 紧绷 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0233 | 紧绷 | 给自己一句话 | 夜间 | tense/kindness/night | 情绪花园 → 紧绷 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0234 | 紧绷 | 给自己一句话 | 独处 | tense/kindness/alone | 情绪花园 → 紧绷 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0235 | 紧绷 | 给自己一句话 | 通勤 | tense/kindness/commute | 情绪花园 → 紧绷 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0236 | 紧绷 | 给自己一句话 | 工作间隙 | tense/kindness/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0237 | 紧绷 | 给自己一句话 | 完成之后 | tense/kindness/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0238 | 紧绷 | 给自己一句话 | 等待中 | tense/kindness/waiting | 情绪花园 → 紧绷 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0239 | 紧绷 | 给自己一句话 | 雨天 | tense/kindness/rainyDay | 情绪花园 → 紧绷 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0240 | 紧绷 | 给自己一句话 | 特别日子 | tense/kindness/specialDay | 情绪花园 → 紧绷 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0241 | 紧绷 | 感官锚点 | 晨间 | tense/anchor/morning | 情绪花园 → 紧绷 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0242 | 紧绷 | 感官锚点 | 午间 | tense/anchor/midday | 情绪花园 → 紧绷 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0243 | 紧绷 | 感官锚点 | 夜间 | tense/anchor/night | 情绪花园 → 紧绷 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0244 | 紧绷 | 感官锚点 | 独处 | tense/anchor/alone | 情绪花园 → 紧绷 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0245 | 紧绷 | 感官锚点 | 通勤 | tense/anchor/commute | 情绪花园 → 紧绷 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0246 | 紧绷 | 感官锚点 | 工作间隙 | tense/anchor/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0247 | 紧绷 | 感官锚点 | 完成之后 | tense/anchor/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0248 | 紧绷 | 感官锚点 | 等待中 | tense/anchor/waiting | 情绪花园 → 紧绷 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0249 | 紧绷 | 感官锚点 | 雨天 | tense/anchor/rainyDay | 情绪花园 → 紧绷 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0250 | 紧绷 | 感官锚点 | 特别日子 | tense/anchor/specialDay | 情绪花园 → 紧绷 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0251 | 紧绷 | 微小感谢 | 晨间 | tense/gratitude/morning | 情绪花园 → 紧绷 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0252 | 紧绷 | 微小感谢 | 午间 | tense/gratitude/midday | 情绪花园 → 紧绷 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0253 | 紧绷 | 微小感谢 | 夜间 | tense/gratitude/night | 情绪花园 → 紧绷 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0254 | 紧绷 | 微小感谢 | 独处 | tense/gratitude/alone | 情绪花园 → 紧绷 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0255 | 紧绷 | 微小感谢 | 通勤 | tense/gratitude/commute | 情绪花园 → 紧绷 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0256 | 紧绷 | 微小感谢 | 工作间隙 | tense/gratitude/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0257 | 紧绷 | 微小感谢 | 完成之后 | tense/gratitude/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0258 | 紧绷 | 微小感谢 | 等待中 | tense/gratitude/waiting | 情绪花园 → 紧绷 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0259 | 紧绷 | 微小感谢 | 雨天 | tense/gratitude/rainyDay | 情绪花园 → 紧绷 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0260 | 紧绷 | 微小感谢 | 特别日子 | tense/gratitude/specialDay | 情绪花园 → 紧绷 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0261 | 紧绷 | 收集小胜利 | 晨间 | tense/smallWin/morning | 情绪花园 → 紧绷 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0262 | 紧绷 | 收集小胜利 | 午间 | tense/smallWin/midday | 情绪花园 → 紧绷 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0263 | 紧绷 | 收集小胜利 | 夜间 | tense/smallWin/night | 情绪花园 → 紧绷 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0264 | 紧绷 | 收集小胜利 | 独处 | tense/smallWin/alone | 情绪花园 → 紧绷 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0265 | 紧绷 | 收集小胜利 | 通勤 | tense/smallWin/commute | 情绪花园 → 紧绷 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0266 | 紧绷 | 收集小胜利 | 工作间隙 | tense/smallWin/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0267 | 紧绷 | 收集小胜利 | 完成之后 | tense/smallWin/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0268 | 紧绷 | 收集小胜利 | 等待中 | tense/smallWin/waiting | 情绪花园 → 紧绷 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0269 | 紧绷 | 收集小胜利 | 雨天 | tense/smallWin/rainyDay | 情绪花园 → 紧绷 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0270 | 紧绷 | 收集小胜利 | 特别日子 | tense/smallWin/specialDay | 情绪花园 → 紧绷 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0271 | 紧绷 | 轻连接 | 晨间 | tense/connection/morning | 情绪花园 → 紧绷 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0272 | 紧绷 | 轻连接 | 午间 | tense/connection/midday | 情绪花园 → 紧绷 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0273 | 紧绷 | 轻连接 | 夜间 | tense/connection/night | 情绪花园 → 紧绷 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0274 | 紧绷 | 轻连接 | 独处 | tense/connection/alone | 情绪花园 → 紧绷 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0275 | 紧绷 | 轻连接 | 通勤 | tense/connection/commute | 情绪花园 → 紧绷 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0276 | 紧绷 | 轻连接 | 工作间隙 | tense/connection/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0277 | 紧绷 | 轻连接 | 完成之后 | tense/connection/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0278 | 紧绷 | 轻连接 | 等待中 | tense/connection/waiting | 情绪花园 → 紧绷 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0279 | 紧绷 | 轻连接 | 雨天 | tense/connection/rainyDay | 情绪花园 → 紧绷 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0280 | 紧绷 | 轻连接 | 特别日子 | tense/connection/specialDay | 情绪花园 → 紧绷 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0281 | 紧绷 | 温和重启 | 晨间 | tense/reset/morning | 情绪花园 → 紧绷 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0282 | 紧绷 | 温和重启 | 午间 | tense/reset/midday | 情绪花园 → 紧绷 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0283 | 紧绷 | 温和重启 | 夜间 | tense/reset/night | 情绪花园 → 紧绷 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0284 | 紧绷 | 温和重启 | 独处 | tense/reset/alone | 情绪花园 → 紧绷 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0285 | 紧绷 | 温和重启 | 通勤 | tense/reset/commute | 情绪花园 → 紧绷 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0286 | 紧绷 | 温和重启 | 工作间隙 | tense/reset/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0287 | 紧绷 | 温和重启 | 完成之后 | tense/reset/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0288 | 紧绷 | 温和重启 | 等待中 | tense/reset/waiting | 情绪花园 → 紧绷 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0289 | 紧绷 | 温和重启 | 雨天 | tense/reset/rainyDay | 情绪花园 → 紧绷 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0290 | 紧绷 | 温和重启 | 特别日子 | tense/reset/specialDay | 情绪花园 → 紧绷 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0291 | 紧绷 | 一点玩心 | 晨间 | tense/play/morning | 情绪花园 → 紧绷 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0292 | 紧绷 | 一点玩心 | 午间 | tense/play/midday | 情绪花园 → 紧绷 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0293 | 紧绷 | 一点玩心 | 夜间 | tense/play/night | 情绪花园 → 紧绷 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0294 | 紧绷 | 一点玩心 | 独处 | tense/play/alone | 情绪花园 → 紧绷 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0295 | 紧绷 | 一点玩心 | 通勤 | tense/play/commute | 情绪花园 → 紧绷 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0296 | 紧绷 | 一点玩心 | 工作间隙 | tense/play/workBreak | 情绪花园 → 紧绷 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0297 | 紧绷 | 一点玩心 | 完成之后 | tense/play/afterCompletion | 情绪花园 → 紧绷 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0298 | 紧绷 | 一点玩心 | 等待中 | tense/play/waiting | 情绪花园 → 紧绷 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0299 | 紧绷 | 一点玩心 | 雨天 | tense/play/rainyDay | 情绪花园 → 紧绷 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0300 | 紧绷 | 一点玩心 | 特别日子 | tense/play/specialDay | 情绪花园 → 紧绷 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0301 | 疲惫 | 一分钟觉察 | 晨间 | tired/notice/morning | 情绪花园 → 疲惫 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0302 | 疲惫 | 一分钟觉察 | 午间 | tired/notice/midday | 情绪花园 → 疲惫 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0303 | 疲惫 | 一分钟觉察 | 夜间 | tired/notice/night | 情绪花园 → 疲惫 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0304 | 疲惫 | 一分钟觉察 | 独处 | tired/notice/alone | 情绪花园 → 疲惫 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0305 | 疲惫 | 一分钟觉察 | 通勤 | tired/notice/commute | 情绪花园 → 疲惫 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0306 | 疲惫 | 一分钟觉察 | 工作间隙 | tired/notice/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0307 | 疲惫 | 一分钟觉察 | 完成之后 | tired/notice/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0308 | 疲惫 | 一分钟觉察 | 等待中 | tired/notice/waiting | 情绪花园 → 疲惫 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0309 | 疲惫 | 一分钟觉察 | 雨天 | tired/notice/rainyDay | 情绪花园 → 疲惫 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0310 | 疲惫 | 一分钟觉察 | 特别日子 | tired/notice/specialDay | 情绪花园 → 疲惫 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0311 | 疲惫 | 呼吸节拍 | 晨间 | tired/breath/morning | 情绪花园 → 疲惫 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0312 | 疲惫 | 呼吸节拍 | 午间 | tired/breath/midday | 情绪花园 → 疲惫 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0313 | 疲惫 | 呼吸节拍 | 夜间 | tired/breath/night | 情绪花园 → 疲惫 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0314 | 疲惫 | 呼吸节拍 | 独处 | tired/breath/alone | 情绪花园 → 疲惫 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0315 | 疲惫 | 呼吸节拍 | 通勤 | tired/breath/commute | 情绪花园 → 疲惫 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0316 | 疲惫 | 呼吸节拍 | 工作间隙 | tired/breath/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0317 | 疲惫 | 呼吸节拍 | 完成之后 | tired/breath/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0318 | 疲惫 | 呼吸节拍 | 等待中 | tired/breath/waiting | 情绪花园 → 疲惫 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0319 | 疲惫 | 呼吸节拍 | 雨天 | tired/breath/rainyDay | 情绪花园 → 疲惫 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0320 | 疲惫 | 呼吸节拍 | 特别日子 | tired/breath/specialDay | 情绪花园 → 疲惫 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0321 | 疲惫 | 给感受命名 | 晨间 | tired/name/morning | 情绪花园 → 疲惫 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0322 | 疲惫 | 给感受命名 | 午间 | tired/name/midday | 情绪花园 → 疲惫 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0323 | 疲惫 | 给感受命名 | 夜间 | tired/name/night | 情绪花园 → 疲惫 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0324 | 疲惫 | 给感受命名 | 独处 | tired/name/alone | 情绪花园 → 疲惫 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0325 | 疲惫 | 给感受命名 | 通勤 | tired/name/commute | 情绪花园 → 疲惫 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0326 | 疲惫 | 给感受命名 | 工作间隙 | tired/name/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0327 | 疲惫 | 给感受命名 | 完成之后 | tired/name/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0328 | 疲惫 | 给感受命名 | 等待中 | tired/name/waiting | 情绪花园 → 疲惫 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0329 | 疲惫 | 给感受命名 | 雨天 | tired/name/rainyDay | 情绪花园 → 疲惫 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0330 | 疲惫 | 给感受命名 | 特别日子 | tired/name/specialDay | 情绪花园 → 疲惫 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0331 | 疲惫 | 给自己一句话 | 晨间 | tired/kindness/morning | 情绪花园 → 疲惫 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0332 | 疲惫 | 给自己一句话 | 午间 | tired/kindness/midday | 情绪花园 → 疲惫 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0333 | 疲惫 | 给自己一句话 | 夜间 | tired/kindness/night | 情绪花园 → 疲惫 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0334 | 疲惫 | 给自己一句话 | 独处 | tired/kindness/alone | 情绪花园 → 疲惫 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0335 | 疲惫 | 给自己一句话 | 通勤 | tired/kindness/commute | 情绪花园 → 疲惫 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0336 | 疲惫 | 给自己一句话 | 工作间隙 | tired/kindness/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0337 | 疲惫 | 给自己一句话 | 完成之后 | tired/kindness/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0338 | 疲惫 | 给自己一句话 | 等待中 | tired/kindness/waiting | 情绪花园 → 疲惫 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0339 | 疲惫 | 给自己一句话 | 雨天 | tired/kindness/rainyDay | 情绪花园 → 疲惫 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0340 | 疲惫 | 给自己一句话 | 特别日子 | tired/kindness/specialDay | 情绪花园 → 疲惫 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0341 | 疲惫 | 感官锚点 | 晨间 | tired/anchor/morning | 情绪花园 → 疲惫 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0342 | 疲惫 | 感官锚点 | 午间 | tired/anchor/midday | 情绪花园 → 疲惫 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0343 | 疲惫 | 感官锚点 | 夜间 | tired/anchor/night | 情绪花园 → 疲惫 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0344 | 疲惫 | 感官锚点 | 独处 | tired/anchor/alone | 情绪花园 → 疲惫 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0345 | 疲惫 | 感官锚点 | 通勤 | tired/anchor/commute | 情绪花园 → 疲惫 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0346 | 疲惫 | 感官锚点 | 工作间隙 | tired/anchor/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0347 | 疲惫 | 感官锚点 | 完成之后 | tired/anchor/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0348 | 疲惫 | 感官锚点 | 等待中 | tired/anchor/waiting | 情绪花园 → 疲惫 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0349 | 疲惫 | 感官锚点 | 雨天 | tired/anchor/rainyDay | 情绪花园 → 疲惫 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0350 | 疲惫 | 感官锚点 | 特别日子 | tired/anchor/specialDay | 情绪花园 → 疲惫 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0351 | 疲惫 | 微小感谢 | 晨间 | tired/gratitude/morning | 情绪花园 → 疲惫 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0352 | 疲惫 | 微小感谢 | 午间 | tired/gratitude/midday | 情绪花园 → 疲惫 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0353 | 疲惫 | 微小感谢 | 夜间 | tired/gratitude/night | 情绪花园 → 疲惫 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0354 | 疲惫 | 微小感谢 | 独处 | tired/gratitude/alone | 情绪花园 → 疲惫 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0355 | 疲惫 | 微小感谢 | 通勤 | tired/gratitude/commute | 情绪花园 → 疲惫 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0356 | 疲惫 | 微小感谢 | 工作间隙 | tired/gratitude/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0357 | 疲惫 | 微小感谢 | 完成之后 | tired/gratitude/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0358 | 疲惫 | 微小感谢 | 等待中 | tired/gratitude/waiting | 情绪花园 → 疲惫 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0359 | 疲惫 | 微小感谢 | 雨天 | tired/gratitude/rainyDay | 情绪花园 → 疲惫 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0360 | 疲惫 | 微小感谢 | 特别日子 | tired/gratitude/specialDay | 情绪花园 → 疲惫 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0361 | 疲惫 | 收集小胜利 | 晨间 | tired/smallWin/morning | 情绪花园 → 疲惫 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0362 | 疲惫 | 收集小胜利 | 午间 | tired/smallWin/midday | 情绪花园 → 疲惫 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0363 | 疲惫 | 收集小胜利 | 夜间 | tired/smallWin/night | 情绪花园 → 疲惫 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0364 | 疲惫 | 收集小胜利 | 独处 | tired/smallWin/alone | 情绪花园 → 疲惫 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0365 | 疲惫 | 收集小胜利 | 通勤 | tired/smallWin/commute | 情绪花园 → 疲惫 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0366 | 疲惫 | 收集小胜利 | 工作间隙 | tired/smallWin/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0367 | 疲惫 | 收集小胜利 | 完成之后 | tired/smallWin/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0368 | 疲惫 | 收集小胜利 | 等待中 | tired/smallWin/waiting | 情绪花园 → 疲惫 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0369 | 疲惫 | 收集小胜利 | 雨天 | tired/smallWin/rainyDay | 情绪花园 → 疲惫 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0370 | 疲惫 | 收集小胜利 | 特别日子 | tired/smallWin/specialDay | 情绪花园 → 疲惫 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0371 | 疲惫 | 轻连接 | 晨间 | tired/connection/morning | 情绪花园 → 疲惫 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0372 | 疲惫 | 轻连接 | 午间 | tired/connection/midday | 情绪花园 → 疲惫 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0373 | 疲惫 | 轻连接 | 夜间 | tired/connection/night | 情绪花园 → 疲惫 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0374 | 疲惫 | 轻连接 | 独处 | tired/connection/alone | 情绪花园 → 疲惫 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0375 | 疲惫 | 轻连接 | 通勤 | tired/connection/commute | 情绪花园 → 疲惫 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0376 | 疲惫 | 轻连接 | 工作间隙 | tired/connection/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0377 | 疲惫 | 轻连接 | 完成之后 | tired/connection/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0378 | 疲惫 | 轻连接 | 等待中 | tired/connection/waiting | 情绪花园 → 疲惫 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0379 | 疲惫 | 轻连接 | 雨天 | tired/connection/rainyDay | 情绪花园 → 疲惫 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0380 | 疲惫 | 轻连接 | 特别日子 | tired/connection/specialDay | 情绪花园 → 疲惫 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0381 | 疲惫 | 温和重启 | 晨间 | tired/reset/morning | 情绪花园 → 疲惫 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0382 | 疲惫 | 温和重启 | 午间 | tired/reset/midday | 情绪花园 → 疲惫 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0383 | 疲惫 | 温和重启 | 夜间 | tired/reset/night | 情绪花园 → 疲惫 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0384 | 疲惫 | 温和重启 | 独处 | tired/reset/alone | 情绪花园 → 疲惫 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0385 | 疲惫 | 温和重启 | 通勤 | tired/reset/commute | 情绪花园 → 疲惫 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0386 | 疲惫 | 温和重启 | 工作间隙 | tired/reset/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0387 | 疲惫 | 温和重启 | 完成之后 | tired/reset/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0388 | 疲惫 | 温和重启 | 等待中 | tired/reset/waiting | 情绪花园 → 疲惫 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0389 | 疲惫 | 温和重启 | 雨天 | tired/reset/rainyDay | 情绪花园 → 疲惫 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0390 | 疲惫 | 温和重启 | 特别日子 | tired/reset/specialDay | 情绪花园 → 疲惫 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0391 | 疲惫 | 一点玩心 | 晨间 | tired/play/morning | 情绪花园 → 疲惫 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0392 | 疲惫 | 一点玩心 | 午间 | tired/play/midday | 情绪花园 → 疲惫 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0393 | 疲惫 | 一点玩心 | 夜间 | tired/play/night | 情绪花园 → 疲惫 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0394 | 疲惫 | 一点玩心 | 独处 | tired/play/alone | 情绪花园 → 疲惫 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0395 | 疲惫 | 一点玩心 | 通勤 | tired/play/commute | 情绪花园 → 疲惫 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0396 | 疲惫 | 一点玩心 | 工作间隙 | tired/play/workBreak | 情绪花园 → 疲惫 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0397 | 疲惫 | 一点玩心 | 完成之后 | tired/play/afterCompletion | 情绪花园 → 疲惫 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0398 | 疲惫 | 一点玩心 | 等待中 | tired/play/waiting | 情绪花园 → 疲惫 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0399 | 疲惫 | 一点玩心 | 雨天 | tired/play/rainyDay | 情绪花园 → 疲惫 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0400 | 疲惫 | 一点玩心 | 特别日子 | tired/play/specialDay | 情绪花园 → 疲惫 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0401 | 孤单 | 一分钟觉察 | 晨间 | lonely/notice/morning | 情绪花园 → 孤单 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0402 | 孤单 | 一分钟觉察 | 午间 | lonely/notice/midday | 情绪花园 → 孤单 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0403 | 孤单 | 一分钟觉察 | 夜间 | lonely/notice/night | 情绪花园 → 孤单 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0404 | 孤单 | 一分钟觉察 | 独处 | lonely/notice/alone | 情绪花园 → 孤单 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0405 | 孤单 | 一分钟觉察 | 通勤 | lonely/notice/commute | 情绪花园 → 孤单 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0406 | 孤单 | 一分钟觉察 | 工作间隙 | lonely/notice/workBreak | 情绪花园 → 孤单 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0407 | 孤单 | 一分钟觉察 | 完成之后 | lonely/notice/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0408 | 孤单 | 一分钟觉察 | 等待中 | lonely/notice/waiting | 情绪花园 → 孤单 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0409 | 孤单 | 一分钟觉察 | 雨天 | lonely/notice/rainyDay | 情绪花园 → 孤单 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0410 | 孤单 | 一分钟觉察 | 特别日子 | lonely/notice/specialDay | 情绪花园 → 孤单 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0411 | 孤单 | 呼吸节拍 | 晨间 | lonely/breath/morning | 情绪花园 → 孤单 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0412 | 孤单 | 呼吸节拍 | 午间 | lonely/breath/midday | 情绪花园 → 孤单 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0413 | 孤单 | 呼吸节拍 | 夜间 | lonely/breath/night | 情绪花园 → 孤单 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0414 | 孤单 | 呼吸节拍 | 独处 | lonely/breath/alone | 情绪花园 → 孤单 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0415 | 孤单 | 呼吸节拍 | 通勤 | lonely/breath/commute | 情绪花园 → 孤单 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0416 | 孤单 | 呼吸节拍 | 工作间隙 | lonely/breath/workBreak | 情绪花园 → 孤单 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0417 | 孤单 | 呼吸节拍 | 完成之后 | lonely/breath/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0418 | 孤单 | 呼吸节拍 | 等待中 | lonely/breath/waiting | 情绪花园 → 孤单 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0419 | 孤单 | 呼吸节拍 | 雨天 | lonely/breath/rainyDay | 情绪花园 → 孤单 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0420 | 孤单 | 呼吸节拍 | 特别日子 | lonely/breath/specialDay | 情绪花园 → 孤单 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0421 | 孤单 | 给感受命名 | 晨间 | lonely/name/morning | 情绪花园 → 孤单 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0422 | 孤单 | 给感受命名 | 午间 | lonely/name/midday | 情绪花园 → 孤单 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0423 | 孤单 | 给感受命名 | 夜间 | lonely/name/night | 情绪花园 → 孤单 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0424 | 孤单 | 给感受命名 | 独处 | lonely/name/alone | 情绪花园 → 孤单 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0425 | 孤单 | 给感受命名 | 通勤 | lonely/name/commute | 情绪花园 → 孤单 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0426 | 孤单 | 给感受命名 | 工作间隙 | lonely/name/workBreak | 情绪花园 → 孤单 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0427 | 孤单 | 给感受命名 | 完成之后 | lonely/name/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0428 | 孤单 | 给感受命名 | 等待中 | lonely/name/waiting | 情绪花园 → 孤单 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0429 | 孤单 | 给感受命名 | 雨天 | lonely/name/rainyDay | 情绪花园 → 孤单 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0430 | 孤单 | 给感受命名 | 特别日子 | lonely/name/specialDay | 情绪花园 → 孤单 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0431 | 孤单 | 给自己一句话 | 晨间 | lonely/kindness/morning | 情绪花园 → 孤单 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0432 | 孤单 | 给自己一句话 | 午间 | lonely/kindness/midday | 情绪花园 → 孤单 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0433 | 孤单 | 给自己一句话 | 夜间 | lonely/kindness/night | 情绪花园 → 孤单 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0434 | 孤单 | 给自己一句话 | 独处 | lonely/kindness/alone | 情绪花园 → 孤单 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0435 | 孤单 | 给自己一句话 | 通勤 | lonely/kindness/commute | 情绪花园 → 孤单 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0436 | 孤单 | 给自己一句话 | 工作间隙 | lonely/kindness/workBreak | 情绪花园 → 孤单 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0437 | 孤单 | 给自己一句话 | 完成之后 | lonely/kindness/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0438 | 孤单 | 给自己一句话 | 等待中 | lonely/kindness/waiting | 情绪花园 → 孤单 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0439 | 孤单 | 给自己一句话 | 雨天 | lonely/kindness/rainyDay | 情绪花园 → 孤单 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0440 | 孤单 | 给自己一句话 | 特别日子 | lonely/kindness/specialDay | 情绪花园 → 孤单 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0441 | 孤单 | 感官锚点 | 晨间 | lonely/anchor/morning | 情绪花园 → 孤单 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0442 | 孤单 | 感官锚点 | 午间 | lonely/anchor/midday | 情绪花园 → 孤单 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0443 | 孤单 | 感官锚点 | 夜间 | lonely/anchor/night | 情绪花园 → 孤单 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0444 | 孤单 | 感官锚点 | 独处 | lonely/anchor/alone | 情绪花园 → 孤单 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0445 | 孤单 | 感官锚点 | 通勤 | lonely/anchor/commute | 情绪花园 → 孤单 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0446 | 孤单 | 感官锚点 | 工作间隙 | lonely/anchor/workBreak | 情绪花园 → 孤单 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0447 | 孤单 | 感官锚点 | 完成之后 | lonely/anchor/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0448 | 孤单 | 感官锚点 | 等待中 | lonely/anchor/waiting | 情绪花园 → 孤单 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0449 | 孤单 | 感官锚点 | 雨天 | lonely/anchor/rainyDay | 情绪花园 → 孤单 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0450 | 孤单 | 感官锚点 | 特别日子 | lonely/anchor/specialDay | 情绪花园 → 孤单 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0451 | 孤单 | 微小感谢 | 晨间 | lonely/gratitude/morning | 情绪花园 → 孤单 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0452 | 孤单 | 微小感谢 | 午间 | lonely/gratitude/midday | 情绪花园 → 孤单 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0453 | 孤单 | 微小感谢 | 夜间 | lonely/gratitude/night | 情绪花园 → 孤单 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0454 | 孤单 | 微小感谢 | 独处 | lonely/gratitude/alone | 情绪花园 → 孤单 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0455 | 孤单 | 微小感谢 | 通勤 | lonely/gratitude/commute | 情绪花园 → 孤单 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0456 | 孤单 | 微小感谢 | 工作间隙 | lonely/gratitude/workBreak | 情绪花园 → 孤单 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0457 | 孤单 | 微小感谢 | 完成之后 | lonely/gratitude/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0458 | 孤单 | 微小感谢 | 等待中 | lonely/gratitude/waiting | 情绪花园 → 孤单 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0459 | 孤单 | 微小感谢 | 雨天 | lonely/gratitude/rainyDay | 情绪花园 → 孤单 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0460 | 孤单 | 微小感谢 | 特别日子 | lonely/gratitude/specialDay | 情绪花园 → 孤单 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0461 | 孤单 | 收集小胜利 | 晨间 | lonely/smallWin/morning | 情绪花园 → 孤单 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0462 | 孤单 | 收集小胜利 | 午间 | lonely/smallWin/midday | 情绪花园 → 孤单 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0463 | 孤单 | 收集小胜利 | 夜间 | lonely/smallWin/night | 情绪花园 → 孤单 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0464 | 孤单 | 收集小胜利 | 独处 | lonely/smallWin/alone | 情绪花园 → 孤单 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0465 | 孤单 | 收集小胜利 | 通勤 | lonely/smallWin/commute | 情绪花园 → 孤单 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0466 | 孤单 | 收集小胜利 | 工作间隙 | lonely/smallWin/workBreak | 情绪花园 → 孤单 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0467 | 孤单 | 收集小胜利 | 完成之后 | lonely/smallWin/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0468 | 孤单 | 收集小胜利 | 等待中 | lonely/smallWin/waiting | 情绪花园 → 孤单 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0469 | 孤单 | 收集小胜利 | 雨天 | lonely/smallWin/rainyDay | 情绪花园 → 孤单 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0470 | 孤单 | 收集小胜利 | 特别日子 | lonely/smallWin/specialDay | 情绪花园 → 孤单 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0471 | 孤单 | 轻连接 | 晨间 | lonely/connection/morning | 情绪花园 → 孤单 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0472 | 孤单 | 轻连接 | 午间 | lonely/connection/midday | 情绪花园 → 孤单 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0473 | 孤单 | 轻连接 | 夜间 | lonely/connection/night | 情绪花园 → 孤单 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0474 | 孤单 | 轻连接 | 独处 | lonely/connection/alone | 情绪花园 → 孤单 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0475 | 孤单 | 轻连接 | 通勤 | lonely/connection/commute | 情绪花园 → 孤单 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0476 | 孤单 | 轻连接 | 工作间隙 | lonely/connection/workBreak | 情绪花园 → 孤单 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0477 | 孤单 | 轻连接 | 完成之后 | lonely/connection/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0478 | 孤单 | 轻连接 | 等待中 | lonely/connection/waiting | 情绪花园 → 孤单 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0479 | 孤单 | 轻连接 | 雨天 | lonely/connection/rainyDay | 情绪花园 → 孤单 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0480 | 孤单 | 轻连接 | 特别日子 | lonely/connection/specialDay | 情绪花园 → 孤单 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0481 | 孤单 | 温和重启 | 晨间 | lonely/reset/morning | 情绪花园 → 孤单 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0482 | 孤单 | 温和重启 | 午间 | lonely/reset/midday | 情绪花园 → 孤单 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0483 | 孤单 | 温和重启 | 夜间 | lonely/reset/night | 情绪花园 → 孤单 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0484 | 孤单 | 温和重启 | 独处 | lonely/reset/alone | 情绪花园 → 孤单 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0485 | 孤单 | 温和重启 | 通勤 | lonely/reset/commute | 情绪花园 → 孤单 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0486 | 孤单 | 温和重启 | 工作间隙 | lonely/reset/workBreak | 情绪花园 → 孤单 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0487 | 孤单 | 温和重启 | 完成之后 | lonely/reset/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0488 | 孤单 | 温和重启 | 等待中 | lonely/reset/waiting | 情绪花园 → 孤单 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0489 | 孤单 | 温和重启 | 雨天 | lonely/reset/rainyDay | 情绪花园 → 孤单 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0490 | 孤单 | 温和重启 | 特别日子 | lonely/reset/specialDay | 情绪花园 → 孤单 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0491 | 孤单 | 一点玩心 | 晨间 | lonely/play/morning | 情绪花园 → 孤单 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0492 | 孤单 | 一点玩心 | 午间 | lonely/play/midday | 情绪花园 → 孤单 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0493 | 孤单 | 一点玩心 | 夜间 | lonely/play/night | 情绪花园 → 孤单 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0494 | 孤单 | 一点玩心 | 独处 | lonely/play/alone | 情绪花园 → 孤单 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0495 | 孤单 | 一点玩心 | 通勤 | lonely/play/commute | 情绪花园 → 孤单 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0496 | 孤单 | 一点玩心 | 工作间隙 | lonely/play/workBreak | 情绪花园 → 孤单 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0497 | 孤单 | 一点玩心 | 完成之后 | lonely/play/afterCompletion | 情绪花园 → 孤单 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0498 | 孤单 | 一点玩心 | 等待中 | lonely/play/waiting | 情绪花园 → 孤单 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0499 | 孤单 | 一点玩心 | 雨天 | lonely/play/rainyDay | 情绪花园 → 孤单 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0500 | 孤单 | 一点玩心 | 特别日子 | lonely/play/specialDay | 情绪花园 → 孤单 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0501 | 愉快 | 一分钟觉察 | 晨间 | joyful/notice/morning | 情绪花园 → 愉快 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0502 | 愉快 | 一分钟觉察 | 午间 | joyful/notice/midday | 情绪花园 → 愉快 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0503 | 愉快 | 一分钟觉察 | 夜间 | joyful/notice/night | 情绪花园 → 愉快 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0504 | 愉快 | 一分钟觉察 | 独处 | joyful/notice/alone | 情绪花园 → 愉快 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0505 | 愉快 | 一分钟觉察 | 通勤 | joyful/notice/commute | 情绪花园 → 愉快 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0506 | 愉快 | 一分钟觉察 | 工作间隙 | joyful/notice/workBreak | 情绪花园 → 愉快 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0507 | 愉快 | 一分钟觉察 | 完成之后 | joyful/notice/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0508 | 愉快 | 一分钟觉察 | 等待中 | joyful/notice/waiting | 情绪花园 → 愉快 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0509 | 愉快 | 一分钟觉察 | 雨天 | joyful/notice/rainyDay | 情绪花园 → 愉快 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0510 | 愉快 | 一分钟觉察 | 特别日子 | joyful/notice/specialDay | 情绪花园 → 愉快 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0511 | 愉快 | 呼吸节拍 | 晨间 | joyful/breath/morning | 情绪花园 → 愉快 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0512 | 愉快 | 呼吸节拍 | 午间 | joyful/breath/midday | 情绪花园 → 愉快 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0513 | 愉快 | 呼吸节拍 | 夜间 | joyful/breath/night | 情绪花园 → 愉快 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0514 | 愉快 | 呼吸节拍 | 独处 | joyful/breath/alone | 情绪花园 → 愉快 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0515 | 愉快 | 呼吸节拍 | 通勤 | joyful/breath/commute | 情绪花园 → 愉快 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0516 | 愉快 | 呼吸节拍 | 工作间隙 | joyful/breath/workBreak | 情绪花园 → 愉快 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0517 | 愉快 | 呼吸节拍 | 完成之后 | joyful/breath/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0518 | 愉快 | 呼吸节拍 | 等待中 | joyful/breath/waiting | 情绪花园 → 愉快 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0519 | 愉快 | 呼吸节拍 | 雨天 | joyful/breath/rainyDay | 情绪花园 → 愉快 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0520 | 愉快 | 呼吸节拍 | 特别日子 | joyful/breath/specialDay | 情绪花园 → 愉快 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0521 | 愉快 | 给感受命名 | 晨间 | joyful/name/morning | 情绪花园 → 愉快 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0522 | 愉快 | 给感受命名 | 午间 | joyful/name/midday | 情绪花园 → 愉快 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0523 | 愉快 | 给感受命名 | 夜间 | joyful/name/night | 情绪花园 → 愉快 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0524 | 愉快 | 给感受命名 | 独处 | joyful/name/alone | 情绪花园 → 愉快 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0525 | 愉快 | 给感受命名 | 通勤 | joyful/name/commute | 情绪花园 → 愉快 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0526 | 愉快 | 给感受命名 | 工作间隙 | joyful/name/workBreak | 情绪花园 → 愉快 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0527 | 愉快 | 给感受命名 | 完成之后 | joyful/name/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0528 | 愉快 | 给感受命名 | 等待中 | joyful/name/waiting | 情绪花园 → 愉快 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0529 | 愉快 | 给感受命名 | 雨天 | joyful/name/rainyDay | 情绪花园 → 愉快 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0530 | 愉快 | 给感受命名 | 特别日子 | joyful/name/specialDay | 情绪花园 → 愉快 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0531 | 愉快 | 给自己一句话 | 晨间 | joyful/kindness/morning | 情绪花园 → 愉快 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0532 | 愉快 | 给自己一句话 | 午间 | joyful/kindness/midday | 情绪花园 → 愉快 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0533 | 愉快 | 给自己一句话 | 夜间 | joyful/kindness/night | 情绪花园 → 愉快 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0534 | 愉快 | 给自己一句话 | 独处 | joyful/kindness/alone | 情绪花园 → 愉快 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0535 | 愉快 | 给自己一句话 | 通勤 | joyful/kindness/commute | 情绪花园 → 愉快 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0536 | 愉快 | 给自己一句话 | 工作间隙 | joyful/kindness/workBreak | 情绪花园 → 愉快 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0537 | 愉快 | 给自己一句话 | 完成之后 | joyful/kindness/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0538 | 愉快 | 给自己一句话 | 等待中 | joyful/kindness/waiting | 情绪花园 → 愉快 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0539 | 愉快 | 给自己一句话 | 雨天 | joyful/kindness/rainyDay | 情绪花园 → 愉快 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0540 | 愉快 | 给自己一句话 | 特别日子 | joyful/kindness/specialDay | 情绪花园 → 愉快 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0541 | 愉快 | 感官锚点 | 晨间 | joyful/anchor/morning | 情绪花园 → 愉快 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0542 | 愉快 | 感官锚点 | 午间 | joyful/anchor/midday | 情绪花园 → 愉快 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0543 | 愉快 | 感官锚点 | 夜间 | joyful/anchor/night | 情绪花园 → 愉快 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0544 | 愉快 | 感官锚点 | 独处 | joyful/anchor/alone | 情绪花园 → 愉快 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0545 | 愉快 | 感官锚点 | 通勤 | joyful/anchor/commute | 情绪花园 → 愉快 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0546 | 愉快 | 感官锚点 | 工作间隙 | joyful/anchor/workBreak | 情绪花园 → 愉快 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0547 | 愉快 | 感官锚点 | 完成之后 | joyful/anchor/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0548 | 愉快 | 感官锚点 | 等待中 | joyful/anchor/waiting | 情绪花园 → 愉快 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0549 | 愉快 | 感官锚点 | 雨天 | joyful/anchor/rainyDay | 情绪花园 → 愉快 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0550 | 愉快 | 感官锚点 | 特别日子 | joyful/anchor/specialDay | 情绪花园 → 愉快 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0551 | 愉快 | 微小感谢 | 晨间 | joyful/gratitude/morning | 情绪花园 → 愉快 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0552 | 愉快 | 微小感谢 | 午间 | joyful/gratitude/midday | 情绪花园 → 愉快 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0553 | 愉快 | 微小感谢 | 夜间 | joyful/gratitude/night | 情绪花园 → 愉快 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0554 | 愉快 | 微小感谢 | 独处 | joyful/gratitude/alone | 情绪花园 → 愉快 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0555 | 愉快 | 微小感谢 | 通勤 | joyful/gratitude/commute | 情绪花园 → 愉快 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0556 | 愉快 | 微小感谢 | 工作间隙 | joyful/gratitude/workBreak | 情绪花园 → 愉快 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0557 | 愉快 | 微小感谢 | 完成之后 | joyful/gratitude/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0558 | 愉快 | 微小感谢 | 等待中 | joyful/gratitude/waiting | 情绪花园 → 愉快 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0559 | 愉快 | 微小感谢 | 雨天 | joyful/gratitude/rainyDay | 情绪花园 → 愉快 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0560 | 愉快 | 微小感谢 | 特别日子 | joyful/gratitude/specialDay | 情绪花园 → 愉快 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0561 | 愉快 | 收集小胜利 | 晨间 | joyful/smallWin/morning | 情绪花园 → 愉快 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0562 | 愉快 | 收集小胜利 | 午间 | joyful/smallWin/midday | 情绪花园 → 愉快 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0563 | 愉快 | 收集小胜利 | 夜间 | joyful/smallWin/night | 情绪花园 → 愉快 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0564 | 愉快 | 收集小胜利 | 独处 | joyful/smallWin/alone | 情绪花园 → 愉快 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0565 | 愉快 | 收集小胜利 | 通勤 | joyful/smallWin/commute | 情绪花园 → 愉快 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0566 | 愉快 | 收集小胜利 | 工作间隙 | joyful/smallWin/workBreak | 情绪花园 → 愉快 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0567 | 愉快 | 收集小胜利 | 完成之后 | joyful/smallWin/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0568 | 愉快 | 收集小胜利 | 等待中 | joyful/smallWin/waiting | 情绪花园 → 愉快 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0569 | 愉快 | 收集小胜利 | 雨天 | joyful/smallWin/rainyDay | 情绪花园 → 愉快 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0570 | 愉快 | 收集小胜利 | 特别日子 | joyful/smallWin/specialDay | 情绪花园 → 愉快 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0571 | 愉快 | 轻连接 | 晨间 | joyful/connection/morning | 情绪花园 → 愉快 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0572 | 愉快 | 轻连接 | 午间 | joyful/connection/midday | 情绪花园 → 愉快 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0573 | 愉快 | 轻连接 | 夜间 | joyful/connection/night | 情绪花园 → 愉快 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0574 | 愉快 | 轻连接 | 独处 | joyful/connection/alone | 情绪花园 → 愉快 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0575 | 愉快 | 轻连接 | 通勤 | joyful/connection/commute | 情绪花园 → 愉快 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0576 | 愉快 | 轻连接 | 工作间隙 | joyful/connection/workBreak | 情绪花园 → 愉快 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0577 | 愉快 | 轻连接 | 完成之后 | joyful/connection/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0578 | 愉快 | 轻连接 | 等待中 | joyful/connection/waiting | 情绪花园 → 愉快 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0579 | 愉快 | 轻连接 | 雨天 | joyful/connection/rainyDay | 情绪花园 → 愉快 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0580 | 愉快 | 轻连接 | 特别日子 | joyful/connection/specialDay | 情绪花园 → 愉快 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0581 | 愉快 | 温和重启 | 晨间 | joyful/reset/morning | 情绪花园 → 愉快 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0582 | 愉快 | 温和重启 | 午间 | joyful/reset/midday | 情绪花园 → 愉快 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0583 | 愉快 | 温和重启 | 夜间 | joyful/reset/night | 情绪花园 → 愉快 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0584 | 愉快 | 温和重启 | 独处 | joyful/reset/alone | 情绪花园 → 愉快 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0585 | 愉快 | 温和重启 | 通勤 | joyful/reset/commute | 情绪花园 → 愉快 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0586 | 愉快 | 温和重启 | 工作间隙 | joyful/reset/workBreak | 情绪花园 → 愉快 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0587 | 愉快 | 温和重启 | 完成之后 | joyful/reset/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0588 | 愉快 | 温和重启 | 等待中 | joyful/reset/waiting | 情绪花园 → 愉快 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0589 | 愉快 | 温和重启 | 雨天 | joyful/reset/rainyDay | 情绪花园 → 愉快 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0590 | 愉快 | 温和重启 | 特别日子 | joyful/reset/specialDay | 情绪花园 → 愉快 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0591 | 愉快 | 一点玩心 | 晨间 | joyful/play/morning | 情绪花园 → 愉快 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0592 | 愉快 | 一点玩心 | 午间 | joyful/play/midday | 情绪花园 → 愉快 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0593 | 愉快 | 一点玩心 | 夜间 | joyful/play/night | 情绪花园 → 愉快 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0594 | 愉快 | 一点玩心 | 独处 | joyful/play/alone | 情绪花园 → 愉快 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0595 | 愉快 | 一点玩心 | 通勤 | joyful/play/commute | 情绪花园 → 愉快 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0596 | 愉快 | 一点玩心 | 工作间隙 | joyful/play/workBreak | 情绪花园 → 愉快 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0597 | 愉快 | 一点玩心 | 完成之后 | joyful/play/afterCompletion | 情绪花园 → 愉快 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0598 | 愉快 | 一点玩心 | 等待中 | joyful/play/waiting | 情绪花园 → 愉快 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0599 | 愉快 | 一点玩心 | 雨天 | joyful/play/rainyDay | 情绪花园 → 愉快 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0600 | 愉快 | 一点玩心 | 特别日子 | joyful/play/specialDay | 情绪花园 → 愉快 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0601 | 有成就感 | 一分钟觉察 | 晨间 | proud/notice/morning | 情绪花园 → 有成就感 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0602 | 有成就感 | 一分钟觉察 | 午间 | proud/notice/midday | 情绪花园 → 有成就感 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0603 | 有成就感 | 一分钟觉察 | 夜间 | proud/notice/night | 情绪花园 → 有成就感 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0604 | 有成就感 | 一分钟觉察 | 独处 | proud/notice/alone | 情绪花园 → 有成就感 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0605 | 有成就感 | 一分钟觉察 | 通勤 | proud/notice/commute | 情绪花园 → 有成就感 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0606 | 有成就感 | 一分钟觉察 | 工作间隙 | proud/notice/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0607 | 有成就感 | 一分钟觉察 | 完成之后 | proud/notice/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0608 | 有成就感 | 一分钟觉察 | 等待中 | proud/notice/waiting | 情绪花园 → 有成就感 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0609 | 有成就感 | 一分钟觉察 | 雨天 | proud/notice/rainyDay | 情绪花园 → 有成就感 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0610 | 有成就感 | 一分钟觉察 | 特别日子 | proud/notice/specialDay | 情绪花园 → 有成就感 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0611 | 有成就感 | 呼吸节拍 | 晨间 | proud/breath/morning | 情绪花园 → 有成就感 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0612 | 有成就感 | 呼吸节拍 | 午间 | proud/breath/midday | 情绪花园 → 有成就感 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0613 | 有成就感 | 呼吸节拍 | 夜间 | proud/breath/night | 情绪花园 → 有成就感 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0614 | 有成就感 | 呼吸节拍 | 独处 | proud/breath/alone | 情绪花园 → 有成就感 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0615 | 有成就感 | 呼吸节拍 | 通勤 | proud/breath/commute | 情绪花园 → 有成就感 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0616 | 有成就感 | 呼吸节拍 | 工作间隙 | proud/breath/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0617 | 有成就感 | 呼吸节拍 | 完成之后 | proud/breath/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0618 | 有成就感 | 呼吸节拍 | 等待中 | proud/breath/waiting | 情绪花园 → 有成就感 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0619 | 有成就感 | 呼吸节拍 | 雨天 | proud/breath/rainyDay | 情绪花园 → 有成就感 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0620 | 有成就感 | 呼吸节拍 | 特别日子 | proud/breath/specialDay | 情绪花园 → 有成就感 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0621 | 有成就感 | 给感受命名 | 晨间 | proud/name/morning | 情绪花园 → 有成就感 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0622 | 有成就感 | 给感受命名 | 午间 | proud/name/midday | 情绪花园 → 有成就感 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0623 | 有成就感 | 给感受命名 | 夜间 | proud/name/night | 情绪花园 → 有成就感 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0624 | 有成就感 | 给感受命名 | 独处 | proud/name/alone | 情绪花园 → 有成就感 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0625 | 有成就感 | 给感受命名 | 通勤 | proud/name/commute | 情绪花园 → 有成就感 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0626 | 有成就感 | 给感受命名 | 工作间隙 | proud/name/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0627 | 有成就感 | 给感受命名 | 完成之后 | proud/name/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0628 | 有成就感 | 给感受命名 | 等待中 | proud/name/waiting | 情绪花园 → 有成就感 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0629 | 有成就感 | 给感受命名 | 雨天 | proud/name/rainyDay | 情绪花园 → 有成就感 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0630 | 有成就感 | 给感受命名 | 特别日子 | proud/name/specialDay | 情绪花园 → 有成就感 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0631 | 有成就感 | 给自己一句话 | 晨间 | proud/kindness/morning | 情绪花园 → 有成就感 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0632 | 有成就感 | 给自己一句话 | 午间 | proud/kindness/midday | 情绪花园 → 有成就感 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0633 | 有成就感 | 给自己一句话 | 夜间 | proud/kindness/night | 情绪花园 → 有成就感 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0634 | 有成就感 | 给自己一句话 | 独处 | proud/kindness/alone | 情绪花园 → 有成就感 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0635 | 有成就感 | 给自己一句话 | 通勤 | proud/kindness/commute | 情绪花园 → 有成就感 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0636 | 有成就感 | 给自己一句话 | 工作间隙 | proud/kindness/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0637 | 有成就感 | 给自己一句话 | 完成之后 | proud/kindness/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0638 | 有成就感 | 给自己一句话 | 等待中 | proud/kindness/waiting | 情绪花园 → 有成就感 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0639 | 有成就感 | 给自己一句话 | 雨天 | proud/kindness/rainyDay | 情绪花园 → 有成就感 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0640 | 有成就感 | 给自己一句话 | 特别日子 | proud/kindness/specialDay | 情绪花园 → 有成就感 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0641 | 有成就感 | 感官锚点 | 晨间 | proud/anchor/morning | 情绪花园 → 有成就感 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0642 | 有成就感 | 感官锚点 | 午间 | proud/anchor/midday | 情绪花园 → 有成就感 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0643 | 有成就感 | 感官锚点 | 夜间 | proud/anchor/night | 情绪花园 → 有成就感 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0644 | 有成就感 | 感官锚点 | 独处 | proud/anchor/alone | 情绪花园 → 有成就感 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0645 | 有成就感 | 感官锚点 | 通勤 | proud/anchor/commute | 情绪花园 → 有成就感 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0646 | 有成就感 | 感官锚点 | 工作间隙 | proud/anchor/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0647 | 有成就感 | 感官锚点 | 完成之后 | proud/anchor/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0648 | 有成就感 | 感官锚点 | 等待中 | proud/anchor/waiting | 情绪花园 → 有成就感 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0649 | 有成就感 | 感官锚点 | 雨天 | proud/anchor/rainyDay | 情绪花园 → 有成就感 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0650 | 有成就感 | 感官锚点 | 特别日子 | proud/anchor/specialDay | 情绪花园 → 有成就感 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0651 | 有成就感 | 微小感谢 | 晨间 | proud/gratitude/morning | 情绪花园 → 有成就感 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0652 | 有成就感 | 微小感谢 | 午间 | proud/gratitude/midday | 情绪花园 → 有成就感 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0653 | 有成就感 | 微小感谢 | 夜间 | proud/gratitude/night | 情绪花园 → 有成就感 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0654 | 有成就感 | 微小感谢 | 独处 | proud/gratitude/alone | 情绪花园 → 有成就感 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0655 | 有成就感 | 微小感谢 | 通勤 | proud/gratitude/commute | 情绪花园 → 有成就感 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0656 | 有成就感 | 微小感谢 | 工作间隙 | proud/gratitude/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0657 | 有成就感 | 微小感谢 | 完成之后 | proud/gratitude/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0658 | 有成就感 | 微小感谢 | 等待中 | proud/gratitude/waiting | 情绪花园 → 有成就感 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0659 | 有成就感 | 微小感谢 | 雨天 | proud/gratitude/rainyDay | 情绪花园 → 有成就感 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0660 | 有成就感 | 微小感谢 | 特别日子 | proud/gratitude/specialDay | 情绪花园 → 有成就感 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0661 | 有成就感 | 收集小胜利 | 晨间 | proud/smallWin/morning | 情绪花园 → 有成就感 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0662 | 有成就感 | 收集小胜利 | 午间 | proud/smallWin/midday | 情绪花园 → 有成就感 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0663 | 有成就感 | 收集小胜利 | 夜间 | proud/smallWin/night | 情绪花园 → 有成就感 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0664 | 有成就感 | 收集小胜利 | 独处 | proud/smallWin/alone | 情绪花园 → 有成就感 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0665 | 有成就感 | 收集小胜利 | 通勤 | proud/smallWin/commute | 情绪花园 → 有成就感 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0666 | 有成就感 | 收集小胜利 | 工作间隙 | proud/smallWin/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0667 | 有成就感 | 收集小胜利 | 完成之后 | proud/smallWin/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0668 | 有成就感 | 收集小胜利 | 等待中 | proud/smallWin/waiting | 情绪花园 → 有成就感 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0669 | 有成就感 | 收集小胜利 | 雨天 | proud/smallWin/rainyDay | 情绪花园 → 有成就感 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0670 | 有成就感 | 收集小胜利 | 特别日子 | proud/smallWin/specialDay | 情绪花园 → 有成就感 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0671 | 有成就感 | 轻连接 | 晨间 | proud/connection/morning | 情绪花园 → 有成就感 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0672 | 有成就感 | 轻连接 | 午间 | proud/connection/midday | 情绪花园 → 有成就感 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0673 | 有成就感 | 轻连接 | 夜间 | proud/connection/night | 情绪花园 → 有成就感 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0674 | 有成就感 | 轻连接 | 独处 | proud/connection/alone | 情绪花园 → 有成就感 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0675 | 有成就感 | 轻连接 | 通勤 | proud/connection/commute | 情绪花园 → 有成就感 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0676 | 有成就感 | 轻连接 | 工作间隙 | proud/connection/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0677 | 有成就感 | 轻连接 | 完成之后 | proud/connection/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0678 | 有成就感 | 轻连接 | 等待中 | proud/connection/waiting | 情绪花园 → 有成就感 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0679 | 有成就感 | 轻连接 | 雨天 | proud/connection/rainyDay | 情绪花园 → 有成就感 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0680 | 有成就感 | 轻连接 | 特别日子 | proud/connection/specialDay | 情绪花园 → 有成就感 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0681 | 有成就感 | 温和重启 | 晨间 | proud/reset/morning | 情绪花园 → 有成就感 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0682 | 有成就感 | 温和重启 | 午间 | proud/reset/midday | 情绪花园 → 有成就感 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0683 | 有成就感 | 温和重启 | 夜间 | proud/reset/night | 情绪花园 → 有成就感 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0684 | 有成就感 | 温和重启 | 独处 | proud/reset/alone | 情绪花园 → 有成就感 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0685 | 有成就感 | 温和重启 | 通勤 | proud/reset/commute | 情绪花园 → 有成就感 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0686 | 有成就感 | 温和重启 | 工作间隙 | proud/reset/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0687 | 有成就感 | 温和重启 | 完成之后 | proud/reset/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0688 | 有成就感 | 温和重启 | 等待中 | proud/reset/waiting | 情绪花园 → 有成就感 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0689 | 有成就感 | 温和重启 | 雨天 | proud/reset/rainyDay | 情绪花园 → 有成就感 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0690 | 有成就感 | 温和重启 | 特别日子 | proud/reset/specialDay | 情绪花园 → 有成就感 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0691 | 有成就感 | 一点玩心 | 晨间 | proud/play/morning | 情绪花园 → 有成就感 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0692 | 有成就感 | 一点玩心 | 午间 | proud/play/midday | 情绪花园 → 有成就感 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0693 | 有成就感 | 一点玩心 | 夜间 | proud/play/night | 情绪花园 → 有成就感 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0694 | 有成就感 | 一点玩心 | 独处 | proud/play/alone | 情绪花园 → 有成就感 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0695 | 有成就感 | 一点玩心 | 通勤 | proud/play/commute | 情绪花园 → 有成就感 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0696 | 有成就感 | 一点玩心 | 工作间隙 | proud/play/workBreak | 情绪花园 → 有成就感 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0697 | 有成就感 | 一点玩心 | 完成之后 | proud/play/afterCompletion | 情绪花园 → 有成就感 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0698 | 有成就感 | 一点玩心 | 等待中 | proud/play/waiting | 情绪花园 → 有成就感 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0699 | 有成就感 | 一点玩心 | 雨天 | proud/play/rainyDay | 情绪花园 → 有成就感 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0700 | 有成就感 | 一点玩心 | 特别日子 | proud/play/specialDay | 情绪花园 → 有成就感 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0701 | 不确定 | 一分钟觉察 | 晨间 | uncertain/notice/morning | 情绪花园 → 不确定 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0702 | 不确定 | 一分钟觉察 | 午间 | uncertain/notice/midday | 情绪花园 → 不确定 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0703 | 不确定 | 一分钟觉察 | 夜间 | uncertain/notice/night | 情绪花园 → 不确定 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0704 | 不确定 | 一分钟觉察 | 独处 | uncertain/notice/alone | 情绪花园 → 不确定 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0705 | 不确定 | 一分钟觉察 | 通勤 | uncertain/notice/commute | 情绪花园 → 不确定 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0706 | 不确定 | 一分钟觉察 | 工作间隙 | uncertain/notice/workBreak | 情绪花园 → 不确定 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0707 | 不确定 | 一分钟觉察 | 完成之后 | uncertain/notice/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0708 | 不确定 | 一分钟觉察 | 等待中 | uncertain/notice/waiting | 情绪花园 → 不确定 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0709 | 不确定 | 一分钟觉察 | 雨天 | uncertain/notice/rainyDay | 情绪花园 → 不确定 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0710 | 不确定 | 一分钟觉察 | 特别日子 | uncertain/notice/specialDay | 情绪花园 → 不确定 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0711 | 不确定 | 呼吸节拍 | 晨间 | uncertain/breath/morning | 情绪花园 → 不确定 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0712 | 不确定 | 呼吸节拍 | 午间 | uncertain/breath/midday | 情绪花园 → 不确定 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0713 | 不确定 | 呼吸节拍 | 夜间 | uncertain/breath/night | 情绪花园 → 不确定 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0714 | 不确定 | 呼吸节拍 | 独处 | uncertain/breath/alone | 情绪花园 → 不确定 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0715 | 不确定 | 呼吸节拍 | 通勤 | uncertain/breath/commute | 情绪花园 → 不确定 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0716 | 不确定 | 呼吸节拍 | 工作间隙 | uncertain/breath/workBreak | 情绪花园 → 不确定 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0717 | 不确定 | 呼吸节拍 | 完成之后 | uncertain/breath/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0718 | 不确定 | 呼吸节拍 | 等待中 | uncertain/breath/waiting | 情绪花园 → 不确定 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0719 | 不确定 | 呼吸节拍 | 雨天 | uncertain/breath/rainyDay | 情绪花园 → 不确定 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0720 | 不确定 | 呼吸节拍 | 特别日子 | uncertain/breath/specialDay | 情绪花园 → 不确定 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0721 | 不确定 | 给感受命名 | 晨间 | uncertain/name/morning | 情绪花园 → 不确定 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0722 | 不确定 | 给感受命名 | 午间 | uncertain/name/midday | 情绪花园 → 不确定 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0723 | 不确定 | 给感受命名 | 夜间 | uncertain/name/night | 情绪花园 → 不确定 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0724 | 不确定 | 给感受命名 | 独处 | uncertain/name/alone | 情绪花园 → 不确定 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0725 | 不确定 | 给感受命名 | 通勤 | uncertain/name/commute | 情绪花园 → 不确定 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0726 | 不确定 | 给感受命名 | 工作间隙 | uncertain/name/workBreak | 情绪花园 → 不确定 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0727 | 不确定 | 给感受命名 | 完成之后 | uncertain/name/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0728 | 不确定 | 给感受命名 | 等待中 | uncertain/name/waiting | 情绪花园 → 不确定 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0729 | 不确定 | 给感受命名 | 雨天 | uncertain/name/rainyDay | 情绪花园 → 不确定 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0730 | 不确定 | 给感受命名 | 特别日子 | uncertain/name/specialDay | 情绪花园 → 不确定 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0731 | 不确定 | 给自己一句话 | 晨间 | uncertain/kindness/morning | 情绪花园 → 不确定 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0732 | 不确定 | 给自己一句话 | 午间 | uncertain/kindness/midday | 情绪花园 → 不确定 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0733 | 不确定 | 给自己一句话 | 夜间 | uncertain/kindness/night | 情绪花园 → 不确定 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0734 | 不确定 | 给自己一句话 | 独处 | uncertain/kindness/alone | 情绪花园 → 不确定 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0735 | 不确定 | 给自己一句话 | 通勤 | uncertain/kindness/commute | 情绪花园 → 不确定 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0736 | 不确定 | 给自己一句话 | 工作间隙 | uncertain/kindness/workBreak | 情绪花园 → 不确定 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0737 | 不确定 | 给自己一句话 | 完成之后 | uncertain/kindness/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0738 | 不确定 | 给自己一句话 | 等待中 | uncertain/kindness/waiting | 情绪花园 → 不确定 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0739 | 不确定 | 给自己一句话 | 雨天 | uncertain/kindness/rainyDay | 情绪花园 → 不确定 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0740 | 不确定 | 给自己一句话 | 特别日子 | uncertain/kindness/specialDay | 情绪花园 → 不确定 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0741 | 不确定 | 感官锚点 | 晨间 | uncertain/anchor/morning | 情绪花园 → 不确定 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0742 | 不确定 | 感官锚点 | 午间 | uncertain/anchor/midday | 情绪花园 → 不确定 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0743 | 不确定 | 感官锚点 | 夜间 | uncertain/anchor/night | 情绪花园 → 不确定 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0744 | 不确定 | 感官锚点 | 独处 | uncertain/anchor/alone | 情绪花园 → 不确定 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0745 | 不确定 | 感官锚点 | 通勤 | uncertain/anchor/commute | 情绪花园 → 不确定 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0746 | 不确定 | 感官锚点 | 工作间隙 | uncertain/anchor/workBreak | 情绪花园 → 不确定 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0747 | 不确定 | 感官锚点 | 完成之后 | uncertain/anchor/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0748 | 不确定 | 感官锚点 | 等待中 | uncertain/anchor/waiting | 情绪花园 → 不确定 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0749 | 不确定 | 感官锚点 | 雨天 | uncertain/anchor/rainyDay | 情绪花园 → 不确定 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0750 | 不确定 | 感官锚点 | 特别日子 | uncertain/anchor/specialDay | 情绪花园 → 不确定 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0751 | 不确定 | 微小感谢 | 晨间 | uncertain/gratitude/morning | 情绪花园 → 不确定 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0752 | 不确定 | 微小感谢 | 午间 | uncertain/gratitude/midday | 情绪花园 → 不确定 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0753 | 不确定 | 微小感谢 | 夜间 | uncertain/gratitude/night | 情绪花园 → 不确定 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0754 | 不确定 | 微小感谢 | 独处 | uncertain/gratitude/alone | 情绪花园 → 不确定 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0755 | 不确定 | 微小感谢 | 通勤 | uncertain/gratitude/commute | 情绪花园 → 不确定 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0756 | 不确定 | 微小感谢 | 工作间隙 | uncertain/gratitude/workBreak | 情绪花园 → 不确定 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0757 | 不确定 | 微小感谢 | 完成之后 | uncertain/gratitude/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0758 | 不确定 | 微小感谢 | 等待中 | uncertain/gratitude/waiting | 情绪花园 → 不确定 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0759 | 不确定 | 微小感谢 | 雨天 | uncertain/gratitude/rainyDay | 情绪花园 → 不确定 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0760 | 不确定 | 微小感谢 | 特别日子 | uncertain/gratitude/specialDay | 情绪花园 → 不确定 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0761 | 不确定 | 收集小胜利 | 晨间 | uncertain/smallWin/morning | 情绪花园 → 不确定 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0762 | 不确定 | 收集小胜利 | 午间 | uncertain/smallWin/midday | 情绪花园 → 不确定 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0763 | 不确定 | 收集小胜利 | 夜间 | uncertain/smallWin/night | 情绪花园 → 不确定 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0764 | 不确定 | 收集小胜利 | 独处 | uncertain/smallWin/alone | 情绪花园 → 不确定 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0765 | 不确定 | 收集小胜利 | 通勤 | uncertain/smallWin/commute | 情绪花园 → 不确定 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0766 | 不确定 | 收集小胜利 | 工作间隙 | uncertain/smallWin/workBreak | 情绪花园 → 不确定 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0767 | 不确定 | 收集小胜利 | 完成之后 | uncertain/smallWin/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0768 | 不确定 | 收集小胜利 | 等待中 | uncertain/smallWin/waiting | 情绪花园 → 不确定 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0769 | 不确定 | 收集小胜利 | 雨天 | uncertain/smallWin/rainyDay | 情绪花园 → 不确定 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0770 | 不确定 | 收集小胜利 | 特别日子 | uncertain/smallWin/specialDay | 情绪花园 → 不确定 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0771 | 不确定 | 轻连接 | 晨间 | uncertain/connection/morning | 情绪花园 → 不确定 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0772 | 不确定 | 轻连接 | 午间 | uncertain/connection/midday | 情绪花园 → 不确定 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0773 | 不确定 | 轻连接 | 夜间 | uncertain/connection/night | 情绪花园 → 不确定 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0774 | 不确定 | 轻连接 | 独处 | uncertain/connection/alone | 情绪花园 → 不确定 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0775 | 不确定 | 轻连接 | 通勤 | uncertain/connection/commute | 情绪花园 → 不确定 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0776 | 不确定 | 轻连接 | 工作间隙 | uncertain/connection/workBreak | 情绪花园 → 不确定 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0777 | 不确定 | 轻连接 | 完成之后 | uncertain/connection/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0778 | 不确定 | 轻连接 | 等待中 | uncertain/connection/waiting | 情绪花园 → 不确定 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0779 | 不确定 | 轻连接 | 雨天 | uncertain/connection/rainyDay | 情绪花园 → 不确定 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0780 | 不确定 | 轻连接 | 特别日子 | uncertain/connection/specialDay | 情绪花园 → 不确定 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0781 | 不确定 | 温和重启 | 晨间 | uncertain/reset/morning | 情绪花园 → 不确定 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0782 | 不确定 | 温和重启 | 午间 | uncertain/reset/midday | 情绪花园 → 不确定 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0783 | 不确定 | 温和重启 | 夜间 | uncertain/reset/night | 情绪花园 → 不确定 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0784 | 不确定 | 温和重启 | 独处 | uncertain/reset/alone | 情绪花园 → 不确定 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0785 | 不确定 | 温和重启 | 通勤 | uncertain/reset/commute | 情绪花园 → 不确定 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0786 | 不确定 | 温和重启 | 工作间隙 | uncertain/reset/workBreak | 情绪花园 → 不确定 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0787 | 不确定 | 温和重启 | 完成之后 | uncertain/reset/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0788 | 不确定 | 温和重启 | 等待中 | uncertain/reset/waiting | 情绪花园 → 不确定 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0789 | 不确定 | 温和重启 | 雨天 | uncertain/reset/rainyDay | 情绪花园 → 不确定 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0790 | 不确定 | 温和重启 | 特别日子 | uncertain/reset/specialDay | 情绪花园 → 不确定 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0791 | 不确定 | 一点玩心 | 晨间 | uncertain/play/morning | 情绪花园 → 不确定 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0792 | 不确定 | 一点玩心 | 午间 | uncertain/play/midday | 情绪花园 → 不确定 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0793 | 不确定 | 一点玩心 | 夜间 | uncertain/play/night | 情绪花园 → 不确定 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0794 | 不确定 | 一点玩心 | 独处 | uncertain/play/alone | 情绪花园 → 不确定 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0795 | 不确定 | 一点玩心 | 通勤 | uncertain/play/commute | 情绪花园 → 不确定 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0796 | 不确定 | 一点玩心 | 工作间隙 | uncertain/play/workBreak | 情绪花园 → 不确定 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0797 | 不确定 | 一点玩心 | 完成之后 | uncertain/play/afterCompletion | 情绪花园 → 不确定 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0798 | 不确定 | 一点玩心 | 等待中 | uncertain/play/waiting | 情绪花园 → 不确定 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0799 | 不确定 | 一点玩心 | 雨天 | uncertain/play/rainyDay | 情绪花园 → 不确定 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0800 | 不确定 | 一点玩心 | 特别日子 | uncertain/play/specialDay | 情绪花园 → 不确定 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0801 | 想专注 | 一分钟觉察 | 晨间 | focused/notice/morning | 情绪花园 → 想专注 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0802 | 想专注 | 一分钟觉察 | 午间 | focused/notice/midday | 情绪花园 → 想专注 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0803 | 想专注 | 一分钟觉察 | 夜间 | focused/notice/night | 情绪花园 → 想专注 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0804 | 想专注 | 一分钟觉察 | 独处 | focused/notice/alone | 情绪花园 → 想专注 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0805 | 想专注 | 一分钟觉察 | 通勤 | focused/notice/commute | 情绪花园 → 想专注 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0806 | 想专注 | 一分钟觉察 | 工作间隙 | focused/notice/workBreak | 情绪花园 → 想专注 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0807 | 想专注 | 一分钟觉察 | 完成之后 | focused/notice/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0808 | 想专注 | 一分钟觉察 | 等待中 | focused/notice/waiting | 情绪花园 → 想专注 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0809 | 想专注 | 一分钟觉察 | 雨天 | focused/notice/rainyDay | 情绪花园 → 想专注 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0810 | 想专注 | 一分钟觉察 | 特别日子 | focused/notice/specialDay | 情绪花园 → 想专注 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0811 | 想专注 | 呼吸节拍 | 晨间 | focused/breath/morning | 情绪花园 → 想专注 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0812 | 想专注 | 呼吸节拍 | 午间 | focused/breath/midday | 情绪花园 → 想专注 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0813 | 想专注 | 呼吸节拍 | 夜间 | focused/breath/night | 情绪花园 → 想专注 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0814 | 想专注 | 呼吸节拍 | 独处 | focused/breath/alone | 情绪花园 → 想专注 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0815 | 想专注 | 呼吸节拍 | 通勤 | focused/breath/commute | 情绪花园 → 想专注 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0816 | 想专注 | 呼吸节拍 | 工作间隙 | focused/breath/workBreak | 情绪花园 → 想专注 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0817 | 想专注 | 呼吸节拍 | 完成之后 | focused/breath/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0818 | 想专注 | 呼吸节拍 | 等待中 | focused/breath/waiting | 情绪花园 → 想专注 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0819 | 想专注 | 呼吸节拍 | 雨天 | focused/breath/rainyDay | 情绪花园 → 想专注 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0820 | 想专注 | 呼吸节拍 | 特别日子 | focused/breath/specialDay | 情绪花园 → 想专注 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0821 | 想专注 | 给感受命名 | 晨间 | focused/name/morning | 情绪花园 → 想专注 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0822 | 想专注 | 给感受命名 | 午间 | focused/name/midday | 情绪花园 → 想专注 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0823 | 想专注 | 给感受命名 | 夜间 | focused/name/night | 情绪花园 → 想专注 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0824 | 想专注 | 给感受命名 | 独处 | focused/name/alone | 情绪花园 → 想专注 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0825 | 想专注 | 给感受命名 | 通勤 | focused/name/commute | 情绪花园 → 想专注 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0826 | 想专注 | 给感受命名 | 工作间隙 | focused/name/workBreak | 情绪花园 → 想专注 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0827 | 想专注 | 给感受命名 | 完成之后 | focused/name/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0828 | 想专注 | 给感受命名 | 等待中 | focused/name/waiting | 情绪花园 → 想专注 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0829 | 想专注 | 给感受命名 | 雨天 | focused/name/rainyDay | 情绪花园 → 想专注 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0830 | 想专注 | 给感受命名 | 特别日子 | focused/name/specialDay | 情绪花园 → 想专注 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0831 | 想专注 | 给自己一句话 | 晨间 | focused/kindness/morning | 情绪花园 → 想专注 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0832 | 想专注 | 给自己一句话 | 午间 | focused/kindness/midday | 情绪花园 → 想专注 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0833 | 想专注 | 给自己一句话 | 夜间 | focused/kindness/night | 情绪花园 → 想专注 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0834 | 想专注 | 给自己一句话 | 独处 | focused/kindness/alone | 情绪花园 → 想专注 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0835 | 想专注 | 给自己一句话 | 通勤 | focused/kindness/commute | 情绪花园 → 想专注 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0836 | 想专注 | 给自己一句话 | 工作间隙 | focused/kindness/workBreak | 情绪花园 → 想专注 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0837 | 想专注 | 给自己一句话 | 完成之后 | focused/kindness/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0838 | 想专注 | 给自己一句话 | 等待中 | focused/kindness/waiting | 情绪花园 → 想专注 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0839 | 想专注 | 给自己一句话 | 雨天 | focused/kindness/rainyDay | 情绪花园 → 想专注 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0840 | 想专注 | 给自己一句话 | 特别日子 | focused/kindness/specialDay | 情绪花园 → 想专注 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0841 | 想专注 | 感官锚点 | 晨间 | focused/anchor/morning | 情绪花园 → 想专注 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0842 | 想专注 | 感官锚点 | 午间 | focused/anchor/midday | 情绪花园 → 想专注 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0843 | 想专注 | 感官锚点 | 夜间 | focused/anchor/night | 情绪花园 → 想专注 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0844 | 想专注 | 感官锚点 | 独处 | focused/anchor/alone | 情绪花园 → 想专注 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0845 | 想专注 | 感官锚点 | 通勤 | focused/anchor/commute | 情绪花园 → 想专注 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0846 | 想专注 | 感官锚点 | 工作间隙 | focused/anchor/workBreak | 情绪花园 → 想专注 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0847 | 想专注 | 感官锚点 | 完成之后 | focused/anchor/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0848 | 想专注 | 感官锚点 | 等待中 | focused/anchor/waiting | 情绪花园 → 想专注 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0849 | 想专注 | 感官锚点 | 雨天 | focused/anchor/rainyDay | 情绪花园 → 想专注 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0850 | 想专注 | 感官锚点 | 特别日子 | focused/anchor/specialDay | 情绪花园 → 想专注 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0851 | 想专注 | 微小感谢 | 晨间 | focused/gratitude/morning | 情绪花园 → 想专注 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0852 | 想专注 | 微小感谢 | 午间 | focused/gratitude/midday | 情绪花园 → 想专注 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0853 | 想专注 | 微小感谢 | 夜间 | focused/gratitude/night | 情绪花园 → 想专注 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0854 | 想专注 | 微小感谢 | 独处 | focused/gratitude/alone | 情绪花园 → 想专注 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0855 | 想专注 | 微小感谢 | 通勤 | focused/gratitude/commute | 情绪花园 → 想专注 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0856 | 想专注 | 微小感谢 | 工作间隙 | focused/gratitude/workBreak | 情绪花园 → 想专注 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0857 | 想专注 | 微小感谢 | 完成之后 | focused/gratitude/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0858 | 想专注 | 微小感谢 | 等待中 | focused/gratitude/waiting | 情绪花园 → 想专注 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0859 | 想专注 | 微小感谢 | 雨天 | focused/gratitude/rainyDay | 情绪花园 → 想专注 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0860 | 想专注 | 微小感谢 | 特别日子 | focused/gratitude/specialDay | 情绪花园 → 想专注 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0861 | 想专注 | 收集小胜利 | 晨间 | focused/smallWin/morning | 情绪花园 → 想专注 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0862 | 想专注 | 收集小胜利 | 午间 | focused/smallWin/midday | 情绪花园 → 想专注 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0863 | 想专注 | 收集小胜利 | 夜间 | focused/smallWin/night | 情绪花园 → 想专注 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0864 | 想专注 | 收集小胜利 | 独处 | focused/smallWin/alone | 情绪花园 → 想专注 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0865 | 想专注 | 收集小胜利 | 通勤 | focused/smallWin/commute | 情绪花园 → 想专注 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0866 | 想专注 | 收集小胜利 | 工作间隙 | focused/smallWin/workBreak | 情绪花园 → 想专注 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0867 | 想专注 | 收集小胜利 | 完成之后 | focused/smallWin/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0868 | 想专注 | 收集小胜利 | 等待中 | focused/smallWin/waiting | 情绪花园 → 想专注 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0869 | 想专注 | 收集小胜利 | 雨天 | focused/smallWin/rainyDay | 情绪花园 → 想专注 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0870 | 想专注 | 收集小胜利 | 特别日子 | focused/smallWin/specialDay | 情绪花园 → 想专注 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0871 | 想专注 | 轻连接 | 晨间 | focused/connection/morning | 情绪花园 → 想专注 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0872 | 想专注 | 轻连接 | 午间 | focused/connection/midday | 情绪花园 → 想专注 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0873 | 想专注 | 轻连接 | 夜间 | focused/connection/night | 情绪花园 → 想专注 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0874 | 想专注 | 轻连接 | 独处 | focused/connection/alone | 情绪花园 → 想专注 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0875 | 想专注 | 轻连接 | 通勤 | focused/connection/commute | 情绪花园 → 想专注 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0876 | 想专注 | 轻连接 | 工作间隙 | focused/connection/workBreak | 情绪花园 → 想专注 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0877 | 想专注 | 轻连接 | 完成之后 | focused/connection/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0878 | 想专注 | 轻连接 | 等待中 | focused/connection/waiting | 情绪花园 → 想专注 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0879 | 想专注 | 轻连接 | 雨天 | focused/connection/rainyDay | 情绪花园 → 想专注 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0880 | 想专注 | 轻连接 | 特别日子 | focused/connection/specialDay | 情绪花园 → 想专注 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0881 | 想专注 | 温和重启 | 晨间 | focused/reset/morning | 情绪花园 → 想专注 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0882 | 想专注 | 温和重启 | 午间 | focused/reset/midday | 情绪花园 → 想专注 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0883 | 想专注 | 温和重启 | 夜间 | focused/reset/night | 情绪花园 → 想专注 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0884 | 想专注 | 温和重启 | 独处 | focused/reset/alone | 情绪花园 → 想专注 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0885 | 想专注 | 温和重启 | 通勤 | focused/reset/commute | 情绪花园 → 想专注 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0886 | 想专注 | 温和重启 | 工作间隙 | focused/reset/workBreak | 情绪花园 → 想专注 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0887 | 想专注 | 温和重启 | 完成之后 | focused/reset/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0888 | 想专注 | 温和重启 | 等待中 | focused/reset/waiting | 情绪花园 → 想专注 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0889 | 想专注 | 温和重启 | 雨天 | focused/reset/rainyDay | 情绪花园 → 想专注 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0890 | 想专注 | 温和重启 | 特别日子 | focused/reset/specialDay | 情绪花园 → 想专注 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0891 | 想专注 | 一点玩心 | 晨间 | focused/play/morning | 情绪花园 → 想专注 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0892 | 想专注 | 一点玩心 | 午间 | focused/play/midday | 情绪花园 → 想专注 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0893 | 想专注 | 一点玩心 | 夜间 | focused/play/night | 情绪花园 → 想专注 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0894 | 想专注 | 一点玩心 | 独处 | focused/play/alone | 情绪花园 → 想专注 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0895 | 想专注 | 一点玩心 | 通勤 | focused/play/commute | 情绪花园 → 想专注 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0896 | 想专注 | 一点玩心 | 工作间隙 | focused/play/workBreak | 情绪花园 → 想专注 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0897 | 想专注 | 一点玩心 | 完成之后 | focused/play/afterCompletion | 情绪花园 → 想专注 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0898 | 想专注 | 一点玩心 | 等待中 | focused/play/waiting | 情绪花园 → 想专注 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0899 | 想专注 | 一点玩心 | 雨天 | focused/play/rainyDay | 情绪花园 → 想专注 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0900 | 想专注 | 一点玩心 | 特别日子 | focused/play/specialDay | 情绪花园 → 想专注 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0901 | 想玩一会 | 一分钟觉察 | 晨间 | playful/notice/morning | 情绪花园 → 想玩一会 → 晨间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0902 | 想玩一会 | 一分钟觉察 | 午间 | playful/notice/midday | 情绪花园 → 想玩一会 → 午间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0903 | 想玩一会 | 一分钟觉察 | 夜间 | playful/notice/night | 情绪花园 → 想玩一会 → 夜间 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0904 | 想玩一会 | 一分钟觉察 | 独处 | playful/notice/alone | 情绪花园 → 想玩一会 → 独处 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0905 | 想玩一会 | 一分钟觉察 | 通勤 | playful/notice/commute | 情绪花园 → 想玩一会 → 通勤 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0906 | 想玩一会 | 一分钟觉察 | 工作间隙 | playful/notice/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0907 | 想玩一会 | 一分钟觉察 | 完成之后 | playful/notice/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0908 | 想玩一会 | 一分钟觉察 | 等待中 | playful/notice/waiting | 情绪花园 → 想玩一会 → 等待中 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0909 | 想玩一会 | 一分钟觉察 | 雨天 | playful/notice/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0910 | 想玩一会 | 一分钟觉察 | 特别日子 | playful/notice/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 一分钟觉察 | CANDIDATE-SOURCE | +| EV-0911 | 想玩一会 | 呼吸节拍 | 晨间 | playful/breath/morning | 情绪花园 → 想玩一会 → 晨间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0912 | 想玩一会 | 呼吸节拍 | 午间 | playful/breath/midday | 情绪花园 → 想玩一会 → 午间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0913 | 想玩一会 | 呼吸节拍 | 夜间 | playful/breath/night | 情绪花园 → 想玩一会 → 夜间 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0914 | 想玩一会 | 呼吸节拍 | 独处 | playful/breath/alone | 情绪花园 → 想玩一会 → 独处 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0915 | 想玩一会 | 呼吸节拍 | 通勤 | playful/breath/commute | 情绪花园 → 想玩一会 → 通勤 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0916 | 想玩一会 | 呼吸节拍 | 工作间隙 | playful/breath/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0917 | 想玩一会 | 呼吸节拍 | 完成之后 | playful/breath/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0918 | 想玩一会 | 呼吸节拍 | 等待中 | playful/breath/waiting | 情绪花园 → 想玩一会 → 等待中 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0919 | 想玩一会 | 呼吸节拍 | 雨天 | playful/breath/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0920 | 想玩一会 | 呼吸节拍 | 特别日子 | playful/breath/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 呼吸节拍 | CANDIDATE-SOURCE | +| EV-0921 | 想玩一会 | 给感受命名 | 晨间 | playful/name/morning | 情绪花园 → 想玩一会 → 晨间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0922 | 想玩一会 | 给感受命名 | 午间 | playful/name/midday | 情绪花园 → 想玩一会 → 午间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0923 | 想玩一会 | 给感受命名 | 夜间 | playful/name/night | 情绪花园 → 想玩一会 → 夜间 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0924 | 想玩一会 | 给感受命名 | 独处 | playful/name/alone | 情绪花园 → 想玩一会 → 独处 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0925 | 想玩一会 | 给感受命名 | 通勤 | playful/name/commute | 情绪花园 → 想玩一会 → 通勤 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0926 | 想玩一会 | 给感受命名 | 工作间隙 | playful/name/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0927 | 想玩一会 | 给感受命名 | 完成之后 | playful/name/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0928 | 想玩一会 | 给感受命名 | 等待中 | playful/name/waiting | 情绪花园 → 想玩一会 → 等待中 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0929 | 想玩一会 | 给感受命名 | 雨天 | playful/name/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0930 | 想玩一会 | 给感受命名 | 特别日子 | playful/name/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 给感受命名 | CANDIDATE-SOURCE | +| EV-0931 | 想玩一会 | 给自己一句话 | 晨间 | playful/kindness/morning | 情绪花园 → 想玩一会 → 晨间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0932 | 想玩一会 | 给自己一句话 | 午间 | playful/kindness/midday | 情绪花园 → 想玩一会 → 午间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0933 | 想玩一会 | 给自己一句话 | 夜间 | playful/kindness/night | 情绪花园 → 想玩一会 → 夜间 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0934 | 想玩一会 | 给自己一句话 | 独处 | playful/kindness/alone | 情绪花园 → 想玩一会 → 独处 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0935 | 想玩一会 | 给自己一句话 | 通勤 | playful/kindness/commute | 情绪花园 → 想玩一会 → 通勤 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0936 | 想玩一会 | 给自己一句话 | 工作间隙 | playful/kindness/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0937 | 想玩一会 | 给自己一句话 | 完成之后 | playful/kindness/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0938 | 想玩一会 | 给自己一句话 | 等待中 | playful/kindness/waiting | 情绪花园 → 想玩一会 → 等待中 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0939 | 想玩一会 | 给自己一句话 | 雨天 | playful/kindness/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0940 | 想玩一会 | 给自己一句话 | 特别日子 | playful/kindness/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 给自己一句话 | CANDIDATE-SOURCE | +| EV-0941 | 想玩一会 | 感官锚点 | 晨间 | playful/anchor/morning | 情绪花园 → 想玩一会 → 晨间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0942 | 想玩一会 | 感官锚点 | 午间 | playful/anchor/midday | 情绪花园 → 想玩一会 → 午间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0943 | 想玩一会 | 感官锚点 | 夜间 | playful/anchor/night | 情绪花园 → 想玩一会 → 夜间 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0944 | 想玩一会 | 感官锚点 | 独处 | playful/anchor/alone | 情绪花园 → 想玩一会 → 独处 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0945 | 想玩一会 | 感官锚点 | 通勤 | playful/anchor/commute | 情绪花园 → 想玩一会 → 通勤 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0946 | 想玩一会 | 感官锚点 | 工作间隙 | playful/anchor/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0947 | 想玩一会 | 感官锚点 | 完成之后 | playful/anchor/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0948 | 想玩一会 | 感官锚点 | 等待中 | playful/anchor/waiting | 情绪花园 → 想玩一会 → 等待中 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0949 | 想玩一会 | 感官锚点 | 雨天 | playful/anchor/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0950 | 想玩一会 | 感官锚点 | 特别日子 | playful/anchor/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 感官锚点 | CANDIDATE-SOURCE | +| EV-0951 | 想玩一会 | 微小感谢 | 晨间 | playful/gratitude/morning | 情绪花园 → 想玩一会 → 晨间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0952 | 想玩一会 | 微小感谢 | 午间 | playful/gratitude/midday | 情绪花园 → 想玩一会 → 午间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0953 | 想玩一会 | 微小感谢 | 夜间 | playful/gratitude/night | 情绪花园 → 想玩一会 → 夜间 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0954 | 想玩一会 | 微小感谢 | 独处 | playful/gratitude/alone | 情绪花园 → 想玩一会 → 独处 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0955 | 想玩一会 | 微小感谢 | 通勤 | playful/gratitude/commute | 情绪花园 → 想玩一会 → 通勤 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0956 | 想玩一会 | 微小感谢 | 工作间隙 | playful/gratitude/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0957 | 想玩一会 | 微小感谢 | 完成之后 | playful/gratitude/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0958 | 想玩一会 | 微小感谢 | 等待中 | playful/gratitude/waiting | 情绪花园 → 想玩一会 → 等待中 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0959 | 想玩一会 | 微小感谢 | 雨天 | playful/gratitude/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0960 | 想玩一会 | 微小感谢 | 特别日子 | playful/gratitude/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 微小感谢 | CANDIDATE-SOURCE | +| EV-0961 | 想玩一会 | 收集小胜利 | 晨间 | playful/smallWin/morning | 情绪花园 → 想玩一会 → 晨间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0962 | 想玩一会 | 收集小胜利 | 午间 | playful/smallWin/midday | 情绪花园 → 想玩一会 → 午间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0963 | 想玩一会 | 收集小胜利 | 夜间 | playful/smallWin/night | 情绪花园 → 想玩一会 → 夜间 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0964 | 想玩一会 | 收集小胜利 | 独处 | playful/smallWin/alone | 情绪花园 → 想玩一会 → 独处 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0965 | 想玩一会 | 收集小胜利 | 通勤 | playful/smallWin/commute | 情绪花园 → 想玩一会 → 通勤 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0966 | 想玩一会 | 收集小胜利 | 工作间隙 | playful/smallWin/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0967 | 想玩一会 | 收集小胜利 | 完成之后 | playful/smallWin/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0968 | 想玩一会 | 收集小胜利 | 等待中 | playful/smallWin/waiting | 情绪花园 → 想玩一会 → 等待中 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0969 | 想玩一会 | 收集小胜利 | 雨天 | playful/smallWin/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0970 | 想玩一会 | 收集小胜利 | 特别日子 | playful/smallWin/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 收集小胜利 | CANDIDATE-SOURCE | +| EV-0971 | 想玩一会 | 轻连接 | 晨间 | playful/connection/morning | 情绪花园 → 想玩一会 → 晨间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0972 | 想玩一会 | 轻连接 | 午间 | playful/connection/midday | 情绪花园 → 想玩一会 → 午间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0973 | 想玩一会 | 轻连接 | 夜间 | playful/connection/night | 情绪花园 → 想玩一会 → 夜间 → 轻连接 | CANDIDATE-SOURCE | +| EV-0974 | 想玩一会 | 轻连接 | 独处 | playful/connection/alone | 情绪花园 → 想玩一会 → 独处 → 轻连接 | CANDIDATE-SOURCE | +| EV-0975 | 想玩一会 | 轻连接 | 通勤 | playful/connection/commute | 情绪花园 → 想玩一会 → 通勤 → 轻连接 | CANDIDATE-SOURCE | +| EV-0976 | 想玩一会 | 轻连接 | 工作间隙 | playful/connection/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 轻连接 | CANDIDATE-SOURCE | +| EV-0977 | 想玩一会 | 轻连接 | 完成之后 | playful/connection/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 轻连接 | CANDIDATE-SOURCE | +| EV-0978 | 想玩一会 | 轻连接 | 等待中 | playful/connection/waiting | 情绪花园 → 想玩一会 → 等待中 → 轻连接 | CANDIDATE-SOURCE | +| EV-0979 | 想玩一会 | 轻连接 | 雨天 | playful/connection/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 轻连接 | CANDIDATE-SOURCE | +| EV-0980 | 想玩一会 | 轻连接 | 特别日子 | playful/connection/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 轻连接 | CANDIDATE-SOURCE | +| EV-0981 | 想玩一会 | 温和重启 | 晨间 | playful/reset/morning | 情绪花园 → 想玩一会 → 晨间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0982 | 想玩一会 | 温和重启 | 午间 | playful/reset/midday | 情绪花园 → 想玩一会 → 午间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0983 | 想玩一会 | 温和重启 | 夜间 | playful/reset/night | 情绪花园 → 想玩一会 → 夜间 → 温和重启 | CANDIDATE-SOURCE | +| EV-0984 | 想玩一会 | 温和重启 | 独处 | playful/reset/alone | 情绪花园 → 想玩一会 → 独处 → 温和重启 | CANDIDATE-SOURCE | +| EV-0985 | 想玩一会 | 温和重启 | 通勤 | playful/reset/commute | 情绪花园 → 想玩一会 → 通勤 → 温和重启 | CANDIDATE-SOURCE | +| EV-0986 | 想玩一会 | 温和重启 | 工作间隙 | playful/reset/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 温和重启 | CANDIDATE-SOURCE | +| EV-0987 | 想玩一会 | 温和重启 | 完成之后 | playful/reset/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 温和重启 | CANDIDATE-SOURCE | +| EV-0988 | 想玩一会 | 温和重启 | 等待中 | playful/reset/waiting | 情绪花园 → 想玩一会 → 等待中 → 温和重启 | CANDIDATE-SOURCE | +| EV-0989 | 想玩一会 | 温和重启 | 雨天 | playful/reset/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 温和重启 | CANDIDATE-SOURCE | +| EV-0990 | 想玩一会 | 温和重启 | 特别日子 | playful/reset/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 温和重启 | CANDIDATE-SOURCE | +| EV-0991 | 想玩一会 | 一点玩心 | 晨间 | playful/play/morning | 情绪花园 → 想玩一会 → 晨间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0992 | 想玩一会 | 一点玩心 | 午间 | playful/play/midday | 情绪花园 → 想玩一会 → 午间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0993 | 想玩一会 | 一点玩心 | 夜间 | playful/play/night | 情绪花园 → 想玩一会 → 夜间 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0994 | 想玩一会 | 一点玩心 | 独处 | playful/play/alone | 情绪花园 → 想玩一会 → 独处 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0995 | 想玩一会 | 一点玩心 | 通勤 | playful/play/commute | 情绪花园 → 想玩一会 → 通勤 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0996 | 想玩一会 | 一点玩心 | 工作间隙 | playful/play/workBreak | 情绪花园 → 想玩一会 → 工作间隙 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0997 | 想玩一会 | 一点玩心 | 完成之后 | playful/play/afterCompletion | 情绪花园 → 想玩一会 → 完成之后 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0998 | 想玩一会 | 一点玩心 | 等待中 | playful/play/waiting | 情绪花园 → 想玩一会 → 等待中 → 一点玩心 | CANDIDATE-SOURCE | +| EV-0999 | 想玩一会 | 一点玩心 | 雨天 | playful/play/rainyDay | 情绪花园 → 想玩一会 → 雨天 → 一点玩心 | CANDIDATE-SOURCE | +| EV-1000 | 想玩一会 | 一点玩心 | 特别日子 | playful/play/specialDay | 情绪花园 → 想玩一会 → 特别日子 → 一点玩心 | CANDIDATE-SOURCE | + +## Runtime acceptance contract + +- `VeilEmotionGardenEngine.rituals.count == 1000`,ID 为连续的 `EV-0001...EV-1000`。 +- 每个 ID 的提示文本、三步操作组合及完成回响均唯一;每种状态有 100 项,每个“状态 + 场景”组合有 10 项且覆盖全部互动类型。 +- 用户可通过两个选择器或全库 ID/关键词搜索到达全部 1000 项;每项都能逐步勾选、启动秒级倒计时、暂停/继续、重置并确认完成。每日三步只从当前状态与场景的 10 项中选择,不隐藏其他条目。 +- 所有条目离线运行、可跳过、不自动发送、不读取聊天、不上传状态,也不宣称诊断或治疗。 +- 状态/场景、稳定 ID 和搜索文本均预建索引;搜索结果限制 50 项,计时器只在运行状态存在,暂停或退出立即释放。 +- `CANDIDATE-SOURCE` 表示完整源码已放入本提案,不表示已合并、已构建、已真机验证或已发布。 diff --git a/project.yml b/project.yml index 2597dca5..34ff3d8c 100644 --- a/project.yml +++ b/project.yml @@ -7,14 +7,20 @@ options: settings: base: SWIFT_VERSION: 5.9 - MARKETING_VERSION: "26.15" - CURRENT_PROJECT_VERSION: "61" + MARKETING_VERSION: "27.0" + CURRENT_PROJECT_VERSION: "62" SWIFT_STRICT_CONCURRENCY: complete IPHONEOS_DEPLOYMENT_TARGET: 15.0 TARGETED_DEVICE_FAMILY: "1,2" DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic packages: + CodeScanner: + url: https://github.com/twostraws/CodeScanner.git + exactVersion: 2.5.2 + SwiftSoup: + url: https://github.com/scinfu/SwiftSoup.git + exactVersion: 2.9.6 ZIPFoundation: url: https://github.com/weichsel/ZIPFoundation.git from: 0.9.19 @@ -51,6 +57,8 @@ targets: - Resources/LocalAI-THIRD-PARTY-NOTICES.txt dependencies: - sdk: libsqlite3.tbd + - package: CodeScanner + - package: SwiftSoup - package: ZIPFoundation - target: VeilBackgroundWidget embed: true diff --git a/scripts/ci-standard-preflight.py b/scripts/ci-standard-preflight.py index 282d3fa3..f94ab0ce 100644 --- a/scripts/ci-standard-preflight.py +++ b/scripts/ci-standard-preflight.py @@ -72,8 +72,8 @@ def check_plist(): if widget.get("CFBundleShortVersionString") != "$(MARKETING_VERSION)" or widget.get("CFBundleVersion") != "$(CURRENT_PROJECT_VERSION)": fail("widget release identity must inherit the app build settings") project = (ROOT / "project.yml").read_text(encoding="utf-8") - if 'MARKETING_VERSION: "26.15"' not in project or 'CURRENT_PROJECT_VERSION: "61"' not in project: - fail("VeilLink 26.15 / Build 61 release identity missing from project.yml") + if 'MARKETING_VERSION: "27.0"' not in project or 'CURRENT_PROJECT_VERSION: "62"' not in project: + fail("VeilLink 27.0 / Build 62 release identity missing from project.yml") modes = set(plist.get("UIBackgroundModes", [])) if not {"bluetooth-central", "bluetooth-peripheral"} <= modes: fail("Bluetooth background modes missing") diff --git a/scripts/verify-core-release.py b/scripts/verify-core-release.py index 7dd9fe1d..72ed5735 100644 --- a/scripts/verify-core-release.py +++ b/scripts/verify-core-release.py @@ -12,8 +12,8 @@ MAX_IPA_BYTES = 80 * 1024 * 1024 MAX_APP_BYTES = 120 * 1024 * 1024 -EXPECTED_VERSION = "26.15" -EXPECTED_BUILD = "61" +EXPECTED_VERSION = "27.0" +EXPECTED_BUILD = "62" EXPECTED_TACTICAL_MAP_SHA256 = "4bd450353ae993b0c8083b97ab5909c8260cd6984be6f6051db1967613edaaa5" EXPECTED_A10_M5_SOURCE_MODEL_HASH = "c5ba826dab1ebe1db02e90e8b4bfd2c060e99586413970fa3bd4d848e378a8b4" EXPECTED_A10_M5_FILES = { diff --git a/scripts/verify-maintenance-r8.py b/scripts/verify-maintenance-r8.py index e8e35b1a..3c301c46 100644 --- a/scripts/verify-maintenance-r8.py +++ b/scripts/verify-maintenance-r8.py @@ -15,7 +15,7 @@ def read(rel): if not re.search(r'CFBundleShortVersionString\s*\$\(MARKETING_VERSION\)',info): fail('version must inherit MARKETING_VERSION') if not re.search(r'CFBundleVersion\s*\$\(CURRENT_PROJECT_VERSION\)',info): fail('build must inherit CURRENT_PROJECT_VERSION') project=read('project.yml') -if 'MARKETING_VERSION: "26.15"' not in project or 'CURRENT_PROJECT_VERSION: "61"' not in project: fail('current release identity missing') +if 'MARKETING_VERSION: "27.0"' not in project or 'CURRENT_PROJECT_VERSION: "62"' not in project: fail('current release identity missing') plane=read('VeilLink/Agent/Integration/VeilAppControlPlane.swift') for n in ['guard permissions.localMutationsEnabled else', 'guard permissions.diagnosticsExportEnabled else', 'case .setResourceFocus, .trimCaches, .refreshBLE']: if n not in plane: fail(f'control invariant missing: {n}') diff --git a/scripts/verify-runtime-maintenance-r12.py b/scripts/verify-runtime-maintenance-r12.py index 72e41c0a..b3c31c21 100644 --- a/scripts/verify-runtime-maintenance-r12.py +++ b/scripts/verify-runtime-maintenance-r12.py @@ -61,8 +61,8 @@ if not re.search(rf'{key}\s*{re.escape(value)}',info): raise SystemExit(f'FAIL plist {key}') project=(ROOT/'project.yml').read_text(encoding='utf-8') -if 'MARKETING_VERSION: "26.15"' not in project or 'CURRENT_PROJECT_VERSION: "61"' not in project: - raise SystemExit('FAIL VeilLink 26.15 / Build 61 release identity') +if 'MARKETING_VERSION: "27.0"' not in project or 'CURRENT_PROJECT_VERSION: "62"' not in project: + raise SystemExit('FAIL VeilLink 27.0 / Build 62 release identity') if 'NSMicrophoneUsageDescription' not in info: raise SystemExit('FAIL microphone permission description') diff --git a/scripts/verify-ui-theme-r9.py b/scripts/verify-ui-theme-r9.py index 2ee8ab2e..f7a66043 100644 --- a/scripts/verify-ui-theme-r9.py +++ b/scripts/verify-ui-theme-r9.py @@ -21,7 +21,7 @@ if '$(MARKETING_VERSION)' not in info or '$(CURRENT_PROJECT_VERSION)' not in info: raise SystemExit('FAIL release identity placeholders') project = (root / 'project.yml').read_text(encoding='utf-8') -if 'MARKETING_VERSION: "26.15"' not in project or 'CURRENT_PROJECT_VERSION: "61"' not in project: +if 'MARKETING_VERSION: "27.0"' not in project or 'CURRENT_PROJECT_VERSION: "62"' not in project: raise SystemExit('FAIL current release identity') if not (root / 'docs/history/ui/V0106_R8_ORIGINAL_THEME/manifest.sha256').is_file(): raise SystemExit('FAIL history manifest') diff --git a/scripts/verify-voice-ptt-r10.py b/scripts/verify-voice-ptt-r10.py index a00b1208..b162fa3d 100644 --- a/scripts/verify-voice-ptt-r10.py +++ b/scripts/verify-voice-ptt-r10.py @@ -22,8 +22,8 @@ for key,value in [('CFBundleShortVersionString','$(MARKETING_VERSION)'),('CFBundleVersion','$(CURRENT_PROJECT_VERSION)')]: if not re.search(rf'{key}\s*{re.escape(value)}',info): raise SystemExit(f'FAIL plist {key}') project=(ROOT/'project.yml').read_text(encoding='utf-8') -if 'MARKETING_VERSION: "26.15"' not in project or 'CURRENT_PROJECT_VERSION: "61"' not in project: - raise SystemExit('FAIL VeilLink 26.15 / Build 61 release identity') +if 'MARKETING_VERSION: "27.0"' not in project or 'CURRENT_PROJECT_VERSION: "62"' not in project: + raise SystemExit('FAIL VeilLink 27.0 / Build 62 release identity') if 'NSMicrophoneUsageDescription' not in info: raise SystemExit('FAIL microphone usage description') # The live PTT path must remain trust-gated in SessionCoordinator. s=(ROOT/'VeilLink/Security/SessionCoordinator.swift').read_text(encoding='utf-8') From 6d6f3a9e7fa3dedf39a30eaa8b1631029578a8a7 Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:25:01 +0000 Subject: [PATCH 2/6] fix: simplify mission incident ranking --- VeilLink/Core/VeilMissionControlEngine.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/VeilLink/Core/VeilMissionControlEngine.swift b/VeilLink/Core/VeilMissionControlEngine.swift index 8d61310c..c74cd86a 100644 --- a/VeilLink/Core/VeilMissionControlEngine.swift +++ b/VeilLink/Core/VeilMissionControlEngine.swift @@ -340,12 +340,19 @@ enum VeilMissionControlEngine { "候选:\(risky.map(\.name).joined(separator: "、"))。需要真机命中路径验证,不直接更改视图层级。") case .incidentCorrelation: let group = Dictionary(grouping: s.warningEvents, by: { $0 }) - let sorted = group.map { ($0.key, $0.value.count) } - .sorted { $0.1 == $1.1 ? $0.0 < $1.0 : $0.1 > $1.1 } - guard let top = sorted.first else { return result(feature, .normal, "没有警告样本", "0", "暂无可归类的事件。") } - return result(feature, top.1 >= 3 ? .caution : .normal, - "最高频异常事件", top.0, - "发生 \(top.1) 次,合计 \(s.warningEvents.count) 条;同名计数不代表已证明因果关系。") + let sortedKeys = group.keys.sorted { lhs, rhs in + let leftCount = group[lhs]?.count ?? 0 + let rightCount = group[rhs]?.count ?? 0 + if leftCount == rightCount { return lhs < rhs } + return leftCount > rightCount + } + guard let topEvent = sortedKeys.first else { + return result(feature, .normal, "没有警告样本", "0", "暂无可归类的事件。") + } + let topCount = group[topEvent]?.count ?? 0 + return result(feature, topCount >= 3 ? .caution : .normal, + "最高频异常事件", topEvent, + "发生 \(topCount) 次,合计 \(s.warningEvents.count) 条;同名计数不代表已证明因果关系。") case .capabilityFallback: let absent = s.requestedCapabilities.subtracting(s.knownCapabilities) if absent.isEmpty { return result(feature, .normal, "请求能力齐全", "全部可用", "实际调用前仍需实时 OS 权限与安全会话检查。") } From e1512380721f189ff1573461f421a814e8d8c106 Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:29:06 +0000 Subject: [PATCH 3/6] fix: share tool clipboard helper --- VeilLink/UI/ToolCenterView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VeilLink/UI/ToolCenterView.swift b/VeilLink/UI/ToolCenterView.swift index d05b78ec..ac741171 100644 --- a/VeilLink/UI/ToolCenterView.swift +++ b/VeilLink/UI/ToolCenterView.swift @@ -957,7 +957,7 @@ struct VeilLinkPulseToolView: View { private let veilToolTextByteLimit = 128 * 1_024 -private enum VeilToolClipboard { +enum VeilToolClipboard { static func copy(_ text: String) { guard !text.isEmpty else { return } let item: [String: Any] = [UTType.utf8PlainText.identifier: text] From f8b80801318a9d8f7f0e9be4a0fea8f74e4e2a57 Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:36:15 +0000 Subject: [PATCH 4/6] test: bind imported suites to app module --- VeilLinkTests/VeilBackupPreflightPolicyTests.swift | 2 +- VeilLinkTests/VeilEntertainmentFeatureGateTests.swift | 2 +- VeilLinkTests/VeilR6FramePacerTests.swift | 4 ---- VeilLinkTests/VeilR6RenderGeometryTests.swift | 4 ---- VeilLinkTests/VeilR6VisualTimelineTests.swift | 4 ---- VeilLinkTests/VeilR7FrameStatsTests.swift | 4 ---- VeilLinkTests/VeilR7RenderCacheTests.swift | 4 ---- VeilLinkTests/VeilRhythmExpeditionTests.swift | 2 +- VeilLinkTests/VeilRhythmLiveConductorTests.swift | 2 +- VeilLinkTests/VeilStationChronicleTests.swift | 2 +- 10 files changed, 5 insertions(+), 25 deletions(-) diff --git a/VeilLinkTests/VeilBackupPreflightPolicyTests.swift b/VeilLinkTests/VeilBackupPreflightPolicyTests.swift index 73d75f52..41512ced 100644 --- a/VeilLinkTests/VeilBackupPreflightPolicyTests.swift +++ b/VeilLinkTests/VeilBackupPreflightPolicyTests.swift @@ -1,6 +1,6 @@ import Foundation import XCTest -@testable import VeilBackupPreflight +@testable import VeilLink final class VeilBackupPreflightPolicyTests: XCTestCase { private typealias P = VeilBackupPreflightPolicy diff --git a/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift b/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift index 70eb3fc4..5e3e0887 100644 --- a/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift +++ b/VeilLinkTests/VeilEntertainmentFeatureGateTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import VeilMegaEngines +@testable import VeilLink final class VeilEntertainmentFeatureGateTests: XCTestCase { typealias Gate = VeilEntertainmentFeatureGate diff --git a/VeilLinkTests/VeilR6FramePacerTests.swift b/VeilLinkTests/VeilR6FramePacerTests.swift index c5fbc2d4..af131f87 100644 --- a/VeilLinkTests/VeilR6FramePacerTests.swift +++ b/VeilLinkTests/VeilR6FramePacerTests.swift @@ -1,9 +1,5 @@ import XCTest -#if canImport(VeilMegaEngines) -@testable import VeilMegaEngines -#elseif canImport(VeilLink) @testable import VeilLink -#endif final class VeilR6FramePacerTests: XCTestCase { func testFirstFrameHasNoTimeJump() { diff --git a/VeilLinkTests/VeilR6RenderGeometryTests.swift b/VeilLinkTests/VeilR6RenderGeometryTests.swift index be7b9b52..ffaa5af0 100644 --- a/VeilLinkTests/VeilR6RenderGeometryTests.swift +++ b/VeilLinkTests/VeilR6RenderGeometryTests.swift @@ -1,9 +1,5 @@ import XCTest -#if canImport(VeilMegaEngines) -@testable import VeilMegaEngines -#elseif canImport(VeilLink) @testable import VeilLink -#endif final class VeilR6RenderGeometryTests: XCTestCase { typealias G = VeilRenderGeometry diff --git a/VeilLinkTests/VeilR6VisualTimelineTests.swift b/VeilLinkTests/VeilR6VisualTimelineTests.swift index f0c396ac..24d99a73 100644 --- a/VeilLinkTests/VeilR6VisualTimelineTests.swift +++ b/VeilLinkTests/VeilR6VisualTimelineTests.swift @@ -1,9 +1,5 @@ import XCTest -#if canImport(VeilMegaEngines) -@testable import VeilMegaEngines -#elseif canImport(VeilLink) @testable import VeilLink -#endif final class VeilR6VisualTimelineTests: XCTestCase { typealias V=VeilRhythmVisualTimeline diff --git a/VeilLinkTests/VeilR7FrameStatsTests.swift b/VeilLinkTests/VeilR7FrameStatsTests.swift index 6f9f9cb6..5724d3fa 100644 --- a/VeilLinkTests/VeilR7FrameStatsTests.swift +++ b/VeilLinkTests/VeilR7FrameStatsTests.swift @@ -1,9 +1,5 @@ import XCTest -#if canImport(VeilMegaEngines) -@testable import VeilMegaEngines -#elseif canImport(VeilLink) @testable import VeilLink -#endif final class VeilR7FrameStatsTests: XCTestCase { func testEmptyReportAndInitialSample() { diff --git a/VeilLinkTests/VeilR7RenderCacheTests.swift b/VeilLinkTests/VeilR7RenderCacheTests.swift index 0dff0255..ebf7fa09 100644 --- a/VeilLinkTests/VeilR7RenderCacheTests.swift +++ b/VeilLinkTests/VeilR7RenderCacheTests.swift @@ -1,9 +1,5 @@ import XCTest -#if canImport(VeilMegaEngines) -@testable import VeilMegaEngines -#elseif canImport(VeilLink) @testable import VeilLink -#endif final class VeilR7RenderCacheTests: XCTestCase { typealias Cache = VeilRhythmRenderCache diff --git a/VeilLinkTests/VeilRhythmExpeditionTests.swift b/VeilLinkTests/VeilRhythmExpeditionTests.swift index 70fe8897..bc2fddd3 100644 --- a/VeilLinkTests/VeilRhythmExpeditionTests.swift +++ b/VeilLinkTests/VeilRhythmExpeditionTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import VeilMegaEngines +@testable import VeilLink final class VeilRhythmExpeditionTests: XCTestCase { typealias E = VeilRhythmExpedition diff --git a/VeilLinkTests/VeilRhythmLiveConductorTests.swift b/VeilLinkTests/VeilRhythmLiveConductorTests.swift index e1368986..ddccddb4 100644 --- a/VeilLinkTests/VeilRhythmLiveConductorTests.swift +++ b/VeilLinkTests/VeilRhythmLiveConductorTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import VeilMegaEngines +@testable import VeilLink final class VeilRhythmLiveConductorTests: XCTestCase { typealias C = VeilRhythmLiveConductor diff --git a/VeilLinkTests/VeilStationChronicleTests.swift b/VeilLinkTests/VeilStationChronicleTests.swift index e95e1301..ceaf90ab 100644 --- a/VeilLinkTests/VeilStationChronicleTests.swift +++ b/VeilLinkTests/VeilStationChronicleTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import VeilMegaEngines +@testable import VeilLink final class VeilStationChronicleTests: XCTestCase { typealias E = VeilStationChronicle From 9c328dcd1a2d36b976af76e73d2990dadec5473e Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:41:48 +0000 Subject: [PATCH 5/6] test: call corrupted backup fixture correctly --- VeilLinkTests/VeilBackupArchiveInspectorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VeilLinkTests/VeilBackupArchiveInspectorTests.swift b/VeilLinkTests/VeilBackupArchiveInspectorTests.swift index 226d30b3..cdbd4356 100644 --- a/VeilLinkTests/VeilBackupArchiveInspectorTests.swift +++ b/VeilLinkTests/VeilBackupArchiveInspectorTests.swift @@ -62,7 +62,7 @@ final class VeilBackupArchiveInspectorTests: XCTestCase { } func testTruncatedArchiveFailsClosed() throws { - let (root, file) = try fixture(corrupted: true) + let (root, file) = try fixture(true) defer { try? FileManager.default.removeItem(at: root) } let report = VeilBackupArchiveInspector.inspect(file, verifyAllCRC: true) XCTAssertFalse(report.permittedToRestore) From 31563166929641dd708c9ce33a16f2134d59add7 Mon Sep 17 00:00:00 2001 From: Zeo <2269627154@qq.com> Date: Sun, 25 Oct 2026 06:53:07 +0000 Subject: [PATCH 6/6] test: correct mutually comfortable UTC slot --- VeilLinkTests/VeilProductivityEngineTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VeilLinkTests/VeilProductivityEngineTests.swift b/VeilLinkTests/VeilProductivityEngineTests.swift index a8fc2cf0..5d41d3b4 100644 --- a/VeilLinkTests/VeilProductivityEngineTests.swift +++ b/VeilLinkTests/VeilProductivityEngineTests.swift @@ -65,7 +65,7 @@ final class VeilProductivityEngineTests: XCTestCase { let slots = try VeilProductivityEngine.meetingSlots(from: start, participants: participants, days: 1, limit: 4) XCTAssertEqual(slots.count, 4) XCTAssertTrue(slots.allSatisfy { $0.comfortableParticipantCount == 2 }) - XCTAssertEqual(slots.first?.date, Date(timeIntervalSince1970: 1_704_096_000)) // 08:00 UTC, 09:00 Berlin + XCTAssertEqual(slots.first?.date, Date(timeIntervalSince1970: 1_704_099_600)) // 09:00 UTC, 10:00 Berlin } func testMeetingPlannerRejectsUnknownTimeZone() {