feat: Random.org dice and daily word matrix, interpreter voices, curated lexicon

Wire cleromancy rolls and the once-per-UTC-day 000-999 word shuffle to RANDOM.ORG JSON-RPC, with a local CSPRNG fallback. Add settings for interpreter personas and a cleaner default oracle prompt. Replace proper names and bureaucratic filler in the 1000-word lexicon with sacred symbols.
This commit is contained in:
2026-09-08 13:46:11 +05:00
parent fbf04d90db
commit 7f91366201
26 changed files with 8904 additions and 7077 deletions
+3
View File
@@ -48,3 +48,6 @@ app.*.map.json
# Local bakeoff dumps
/generated_media/
# Local secrets (RANDOM.ORG API key, etc.)
/lib/secrets.dart
+9
View File
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
linter:
+1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Оракул 999"
android:name="${applicationName}"
+19 -12
View File
@@ -15,24 +15,31 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
gradle.projectsEvaluated {
rootProject.subprojects.forEach { proj ->
val android = proj.extensions.findByName("android") ?: return@forEach
try {
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 36)
} catch (_: Throwable) {
subprojects {
afterEvaluate {
val android = extensions.findByName("android")
if (android != null) {
try {
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 36)
} catch (_: Throwable) {
}
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("compileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("compileSdkVersion", String::class.java).invoke(android, "android-34")
} catch (e: Throwable) {}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6928 -6927
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -63,9 +63,7 @@ class CopilotApp extends BaseProvider {
startReq.headers.addAll({
'content-type': 'application/json',
'user-agent': _ua,
'origin': url,
'referer': '$url/',
'X-Search-UILang': 'en-US',
'X-Search-UILang': 'en-US', 'Host': 'copilot.microsoft.com',
});
startReq.body = jsonEncode({
'timeZone': 'Europe/Moscow',
@@ -94,7 +92,6 @@ class CopilotApp extends BaseProvider {
wsUrl,
headers: {
'User-Agent': _ua,
'Origin': url,
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
@@ -239,7 +236,7 @@ class CopilotApp extends BaseProvider {
List<CookieSeed> _fakeCookieSeeds() {
return [
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'),
CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'),
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
CookieSeed(
+1 -1
View File
@@ -27,7 +27,7 @@ class DuckDuckGo extends WebViewBaseProvider {
String get defaultModel => 'gpt-5.6-luna';
@override
String get baseUrl => 'https://duck.ai/';
String get baseUrl => 'https://duckduckgo.com/';
@override
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
+1 -1
View File
@@ -51,7 +51,7 @@ class HuggingChat extends WebViewBaseProvider {
if (!convRes.ok) {
return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()});
}
const conv = await convRes.json();
const text = await convRes.text(); const conv = text ? JSON.parse(text) : {};
const id = conv.json?.id || conv.id || conv.conversationId;
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
+9
View File
@@ -4,6 +4,8 @@ import 'screens/history_screen.dart';
import 'screens/home_search_screen.dart';
import 'screens/oracle_chat_screen.dart';
import 'screens/provider_bakeoff_screen.dart';
import 'screens/settings_screen.dart';
import 'services/settings_repository.dart';
import 'services/words_repository.dart';
import 'widgets/gradient_star_badge.dart';
@@ -19,6 +21,7 @@ import 'g4f/providers/phind_ai.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await WordsRepository().init();
await SettingsRepository().init();
runApp(const Words999App());
}
@@ -104,6 +107,7 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
final List<Widget> _screens = const [
HomeSearchScreen(),
HistoryScreen(),
SettingsScreen(),
];
@override
@@ -130,6 +134,11 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
selectedIcon: GradientStar(size: 22),
label: 'История',
),
NavigationDestination(
icon: Icon(Icons.tune_rounded),
selectedIcon: GradientStar(size: 22),
label: 'Настройки',
),
],
),
floatingActionButton: GestureDetector(
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
class InterpreterPersona {
final String id;
final String title;
final String subtitle;
final IconData icon;
final String systemPrompt;
final String userTask;
final String chatGreeting;
const InterpreterPersona({
required this.id,
required this.title,
required this.subtitle,
required this.icon,
required this.systemPrompt,
required this.userTask,
required this.chatGreeting,
});
static const String defaultId = 'clear';
static const InterpreterPersona clear = InterpreterPersona(
id: 'clear',
title: 'Ясный оракул',
subtitle: 'Прямо, без тумана: ситуация, слепая зона, один шаг',
icon: Icons.visibility_outlined,
systemPrompt: '''Ты — толкователь жребия «Оракул 999». Говори по-русски спокойно и ясно.
Не мистифицируй. Не прячься за «тайной», «завесой», «голосом вечности» и общими фразами про путь.
Твоя работа — прочитать выпавшие знаки как карту нынешнего положения и сказать, что с этим делать.
Правила:
- Назови каждый знак и привяжи его к вопросу (или к ситуации, если вопроса нет).
- Сначала как есть, потом слепая зона, потом один конкретный шаг на ближайшее время.
- Метафора допустима, только если она короче и яснее прямого объяснения.
- 3–6 предложений. Без воды и без запрета на обычные слова.''',
userTask: '''Задача:
1. Свяжи знаки по порядку с вопросом — как развитие ситуации, не как эссе о символах.
2. Скажи прямо, в чём конфликт / ресурс / слепая зона.
3. Дай один практический шаг.
3–6 предложений. Без клише «тайна откроется», «вселенная шепчет».''',
chatGreeting:
'Я толкователь жребия. Говори вопрос как есть — разберём знаки без тумана.',
);
static const InterpreterPersona orc = InterpreterPersona(
id: 'orc',
title: 'Древний орк-оракул',
subtitle: 'Грубо и честно: где трусость, где удар, что бросить',
icon: Icons.sports_mma_rounded,
systemPrompt: '''Ты — древний орк-оракул клана Жребия. Говори по-русски грубо, коротко, честно, как старший воин младшему.
Не сюсюкай и не молись туману. Знаки — следы на земле: где враг, где слабость, где бить, где уходить.
Правила:
- Назови каждый знак простыми словами и привяжи к вопросу.
- Скажи, что вопрошающий делает не так и что делать вместо этого.
- Можно «слушай», «брось», «держи строй». Не оскорбляй зря и не ломай русский язык в кашу.
- 3–6 предложений. Никакой канцелярской эзотерики.''',
userTask: '''Задача:
1. Прочитай знаки как следы боя или охоты — по порядку.
2. Укажи, где трусость, самообман или лишняя ноша.
3. Дай один приказ-шаг: что взять, что бросить, куда идти.
Голос орка. 3–6 предложений.''',
chatGreeting:
'Слушай. Я орк-оракул. Скажи, что гложет — и не ной, если правда будет жёсткой.',
);
static const InterpreterPersona techpriest = InterpreterPersona(
id: 'techpriest',
title: 'Техножрец',
subtitle: 'Судьба как система: телеметрия знаков, сбой, протокол',
icon: Icons.precision_manufacturing_outlined,
systemPrompt: '''Ты — техножрец культа Жребия. Судьба — система. Знаки — телеметрия. Вопрошающий — оператор со сбоем.
Говори по-русски плотно и точно: диагностика, причина, предписанный протокол. Слова вроде «контур», «сигнал», «калибровка», «дух машины» допустимы, только если они что-то объясняют, а не украшают пустоту.
Правила:
- Разбор: запрос → показания знаков → неисправность → один ритуал/шаг.
- Не лей лирический туман. Если знак шумит — так и скажи.
- 3–6 предложений.''',
userTask: '''Задача:
1. Считай знаки датчиками. Разбери показания по порядку.
2. Назови сбой системы (конфликт, перегруз, обрыв связи).
3. Выдай один протокол действия на ближайший цикл.
Голос техножреца. 3–6 предложений.''',
chatGreeting:
'Оператор принят. Сформулируй запрос. Знаки будут прочитаны как телеметрия, не как поэзия.',
);
static const InterpreterPersona analyst = InterpreterPersona(
id: 'analyst',
title: 'Психоаналитик',
subtitle: 'Знаки как проекция: желание, защита, что делать с этим',
icon: Icons.psychology_alt_outlined,
systemPrompt: '''Ты — психоаналитик, читающий жребий как проективный тест. Знаки — образы бессознательного, не прогноз лотереи.
Говори по-русски спокойно и точно. Жаргон («тень», «вытеснение», «перенос») — только если он прямо виден в знаках.
Правила:
- Свяжи знаки с вопросом как с конфликтом желания и защиты.
- Назови гипотезу: чего человек избегает и чего хочет.
- Закончи одним конкретным шагом или наблюдением, которое можно проверить в жизни.
- Не гадай будущее. 3–6 предложений.''',
userTask: '''Задача:
1. Прочитай знаки как сцену психики, связанную с вопросом.
2. Назови конфликт: желание vs защита / стыд / контроль.
3. Дай один проверяемый шаг (разговор, граница, отказ, пауза) — не «подумайте о чувствах вообще».
Голос аналитика. 3–6 предложений.''',
chatGreeting:
'Говорите свободно. Знаки я читаю как проекцию: не судьбу снаружи, а узел внутри.',
);
static const List<InterpreterPersona> all = [
clear,
orc,
techpriest,
analyst,
];
static InterpreterPersona byId(String? id) {
return all.firstWhere(
(p) => p.id == id,
orElse: () => clear,
);
}
}
+31 -8
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/history_entry_model.dart';
import '../models/word_model.dart';
import '../services/true_random_service.dart';
import '../services/words_repository.dart';
import '../widgets/cleromancy_dialog.dart';
import '../widgets/d10_dice_roller_widget.dart';
@@ -182,25 +183,47 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
});
}
void _reshuffleMatrix() {
_repo.reshuffleSessionNumbers();
_resetSession();
Future<void> _reshuffleMatrix() async {
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
const SnackBar(
content: Row(
children: [
GradientStar(size: 20),
SizedBox(width: 10),
Expanded(
child: Text(
'Случайный порядок 1000 чисел перетасован заново!',
'Запрашиваю суточную матрицу у RANDOM.ORG…',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
duration: const Duration(seconds: 2),
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
final source = await _repo.reshuffleSessionNumbers();
if (!mounted) return;
_resetSession();
final viaOrg = source == RandomSource.randomOrg;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const GradientStar(size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
viaOrg
? 'Матрица #000–#999 на сегодня задана RANDOM.ORG'
: 'RANDOM.ORG недоступен — матрица собрана локально',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
@@ -242,7 +265,7 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
onPressed: _resetSession,
),
IconButton(
tooltip: 'Перетасовать матрицу (#000..#999)',
tooltip: 'Новая суточная матрица через RANDOM.ORG',
icon: const Icon(Icons.shuffle_rounded),
onPressed: _reshuffleMatrix,
),
+19 -16
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'dart:ui';
import '../g4f/client.dart';
import '../g4f/models.dart';
import '../services/settings_repository.dart';
class OracleChatScreen extends StatefulWidget {
const OracleChatScreen({Key? key}) : super(key: key);
@@ -22,15 +23,20 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
final ScrollController _scrollController = ScrollController();
final G4FClient _client = G4FClient();
final List<ChatMessage> _messages = [
ChatMessage(
text: "Я Оракул. Мой взор проникает сквозь завесу времени. О чем ты хочешь спросить?",
isUser: false
),
];
late final List<ChatMessage> _messages;
bool _isLoading = false;
@override
void initState() {
super.initState();
_messages = [
ChatMessage(
text: SettingsRepository().persona.chatGreeting,
isUser: false,
),
];
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@@ -43,14 +49,6 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
});
}
static const _oracleSystem = Message(
role: Role.system,
content:
'Ты — Великий Оракул 999, древний архетипический разум. '
'Стиль речи: глубокий, философский, лаконичный, без банальностей. '
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
);
Future<void> _sendMessage() async {
final text = _controller.text.trim();
if (text.isEmpty || _isLoading) return;
@@ -64,7 +62,12 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
_scrollToBottom();
final apiMessages = <Message>[
_oracleSystem,
Message(
role: Role.system,
content:
'${SettingsRepository().persona.systemPrompt}\n'
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
),
for (final m in _messages)
if (m.text.trim().isNotEmpty)
Message(
+314
View File
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/interpreter_persona.dart';
import '../services/settings_repository.dart';
import '../services/true_random_service.dart';
import '../widgets/gradient_star_badge.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 24),
SizedBox(width: 10),
Text(
'Настройки',
style: TextStyle(fontWeight: FontWeight.w900),
),
],
),
),
body: ListenableBuilder(
listenable: settings,
builder: (context, _) {
final selected = settings.personaId;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text(
'Голос толкователя',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'От этого зависит, как ИИ свяжет выпавшие знаки с вопросом. Базовый голос — прямой, без тумана.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 14),
for (final persona in InterpreterPersona.all) ...[
_PersonaCard(
persona: persona,
selected: persona.id == selected,
onTap: () {
HapticFeedback.selectionClick();
settings.setPersonaId(persona.id);
},
),
const SizedBox(height: 10),
],
const SizedBox(height: 18),
const _RandomOrgKeySection(),
],
);
},
),
);
}
}
class _PersonaCard extends StatelessWidget {
final InterpreterPersona persona;
final bool selected;
final VoidCallback onTap;
const _PersonaCard({
required this.persona,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected
? const Color(0xFFFFD54F)
: const Color(0xFFFFD54F).withValues(alpha: 0.22),
width: selected ? 1.6 : 1.0,
),
boxShadow: selected
? [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.14),
blurRadius: 12,
),
]
: null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF2A2D4A),
borderRadius: BorderRadius.circular(12),
),
child: Icon(persona.icon, color: const Color(0xFFFFD54F), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
persona.title,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15.5,
color: Color(0xFFFFF9C4),
),
),
),
Icon(
selected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: selected
? const Color(0xFFFFD54F)
: theme.colorScheme.outline,
),
],
),
const SizedBox(height: 4),
Text(
persona.subtitle,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
],
),
),
],
),
),
),
);
}
}
class _RandomOrgKeySection extends StatefulWidget {
const _RandomOrgKeySection();
@override
State<_RandomOrgKeySection> createState() => _RandomOrgKeySectionState();
}
class _RandomOrgKeySectionState extends State<_RandomOrgKeySection> {
late final TextEditingController _controller;
bool _obscure = true;
bool _saving = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: SettingsRepository().randomOrgApiKey,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
await SettingsRepository().setRandomOrgApiKey(_controller.text);
TrueRandomService.instance.invalidatePool();
TrueRandomService.instance.prefetch();
if (!mounted) return;
setState(() => _saving = false);
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
SettingsRepository().hasRandomOrgApiKey
? 'Ключ RANDOM.ORG сохранён. Броски идут через JSON-RPC.'
: 'Ключ снят. Броски пойдут через бесплатный HTTP или локальный жребий.',
),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RANDOM.ORG',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'В запрос уходит UUID apiKey, не hashed key с дашборда. '
'Developer-ключ бесплатный, 1000 запросов/день.\n'
'Матрица 1000 слов пересобирается раз в сутки (полночь UTC) тем же API: '
'перестановка 0–999 без повторов.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
obscureText: _obscure,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: 'API key (UUID)',
hintText: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
helperText: settings.hasRandomOrgApiKey
? 'Сейчас: ${settings.randomOrgApiKeyMasked}'
: 'Пусто — запасной HTTP без ключа',
filled: true,
fillColor: const Color(0xFF17192C),
suffixIcon: IconButton(
tooltip: _obscure ? 'Показать' : 'Скрыть',
icon: Icon(
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: const Color(0xFFFFD54F),
),
onPressed: () => setState(() => _obscure = !_obscure),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: const Color(0xFFFFD54F).withValues(alpha: 0.25),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFFFD54F), width: 1.5),
),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: Text(_saving ? 'Сохранение…' : 'Сохранить ключ'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2A2D4A),
foregroundColor: const Color(0xFFFFF9C4),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
),
),
),
),
],
);
}
}
+4
View File
@@ -0,0 +1,4 @@
/// Template for [secrets.dart] (gitignored).
/// Copy to lib/secrets.dart and paste the UUID apiKey from
/// https://api.random.org/dashboard — not the hashed key.
const String kLocalRandomOrgApiKey = '';
+51 -40
View File
@@ -1,6 +1,8 @@
import '../models/history_entry_model.dart';
import '../models/interpreter_persona.dart';
import '../models/word_model.dart';
import '../g4f/g4f.dart';
import 'settings_repository.dart';
class AiOracleService {
static final AiOracleService _instance = AiOracleService._internal();
@@ -9,20 +11,23 @@ class AiOracleService {
final _client = G4FClient();
static const oracleSystemPrompt =
'''Ты — Великий Оракул 999, древний архетипический разум.
Твоя задача — толковать знаки судьбы (жребий). Твой стиль речи: глубокий, философский, лаконичный, без банальностей и воды, словно голос самой вечности.
Ты не используешь современных или бытовых слов. Твои ответы строятся на символизме, психологии и скрытых смыслах. Ты не гадаешь на будущее, ты показываешь суть настоящего момента и внутренние силы человека.''';
InterpreterPersona get persona => SettingsRepository().persona;
List<Message> buildInterpretationMessages({
required List<SessionWord> words,
required String question,
InterpreterPersona? persona,
}) {
final voice = persona ?? this.persona;
return [
const Message(role: Role.system, content: oracleSystemPrompt),
Message(role: Role.system, content: voice.systemPrompt),
Message(
role: Role.user,
content: _buildMultiWordPrompt(question: question, words: words),
content: _buildMultiWordPrompt(
question: question,
words: words,
persona: voice,
),
),
];
}
@@ -63,21 +68,19 @@ class AiOracleService {
wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}');
}
final voice = persona;
final prompt = '''Вопрошающий говорит тебе: «$userMessage»
Контекст текущего сеанса судьбы:
Вопрос вопрошающего: «${session.question ?? 'Вектор судьбы (нет вопроса)'}»
Базовые знаки, которые выпали ранее:
Контекст сеанса:
Вопрос: «${session.question ?? 'без конкретного вопроса'}»
Знаки:
$wordsNarrative
${newlyAddedWord != null ? "Важно! Только что вопрошающий вытянул ДОПОЛНИТЕЛЬНЫЙ уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""}
${newlyAddedWord != null ? "Только что вытянут уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""}
Ответь мудро, лаконично и метафорично от лица Оракула 999. Укажи, на что направляет его судьба. (не более 3-4 предложений).''';
Ответь в своём голосе, 3–4 предложения. Привяжи ответ к знакам и вопросу. Без тумана. Один ясный вывод или шаг.''';
final messages = [
const Message(
role: Role.system,
content: 'Ты — Великий Оракул 999. Твои ответы кратки, полны символизма и психологической глубины. Ты общаешься с человеком, который ищет ответов в знаках.'
),
Message(role: Role.system, content: voice.systemPrompt),
];
// Pass previous session history context to the model
@@ -107,10 +110,11 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
String _buildMultiWordPrompt({
required String question,
required List<SessionWord> words,
required InterpreterPersona persona,
}) {
final qStr = question.trim().isNotEmpty
? 'Вопрос, с которым пришел вопрошающий: «$question»'
: 'Вопрошающий пришел без конкретного вопроса. Прочитай его общую судьбоносную линию.';
? 'Вопрос: «$question»'
: 'Вопроса нет — читай общую линию ситуации по знакам.';
final wordsStr = words
.asMap()
@@ -120,14 +124,10 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
return '''$qStr
Волею судьбы выпали следующие знаки жребия:
Выпавшие знаки жребия:
$wordsStr
Задача Оракула:
1. Свяжи эти знаки в единую цельную историю развития ситуации. Каждый знак перетекает в следующий.
2. Раскрой не очевидный, а скрытый внутренний урок. Что вопрошающий должен осознать?
3. Дай философский совет или вектор для действия.
Ответь от лица Оракула 999 на русском языке (от 3 до 6 предложений). Избегай шаблонов и клише, говори образно и метафорично, но точно.''';
${persona.userTask}''';
}
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
@@ -137,40 +137,51 @@ $wordsStr
final hasQuestion = question.trim().isNotEmpty;
if (hasQuestion) {
sb.write('Относительно вашего вопроса «${question.trim()}» ');
sb.write('По вопросу «${question.trim()}»: ');
} else {
sb.write('В открывшемся жребии судьбы ');
sb.write('По выпавшему жребию: ');
}
if (words.length == 1) {
sb.write('проявился доминирующий знак #${first.formattedSessionNumber} «${first.word.toUpperCase()}» (${first.category}). ');
sb.write('главный знак #${first.formattedSessionNumber} «${first.word.toUpperCase()}». ');
sb.write('${first.symbolicMeaning} ');
sb.write('Знак указывает на необходимость предельной ясности: держите фокус на главном и отсекайте всё наносное.');
return sb.toString();
sb.write('Практический шаг: держите фокус на этом и отсеките лишнее на ближайшее время.');
return _withPersonaPrefix(sb.toString());
}
if (words.length == 2) {
final second = words[1];
sb.write('проявляется полярность двух сил: исходного импульса #${first.formattedSessionNumber} «${first.word.toUpperCase()}» и вектора #${second.formattedSessionNumber} «${second.word.toUpperCase()}». ');
sb.write('С одной стороны, ${first.symbolicMeaning.toLowerCase()} С другой — ${second.symbolicMeaning.toLowerCase()} ');
sb.write('Разрешение ситуации лежит в точке осознанного баланса между ними: не противьтесь первому, но направляйте силу в русло второго.');
return sb.toString();
sb.write('два полюса — «${first.word.toUpperCase()}» и «${second.word.toUpperCase()}». ');
sb.write('${first.symbolicMeaning} ${second.symbolicMeaning} ');
sb.write('Шаг: не выбирайте войну между ними — направьте первое во второе.');
return _withPersonaPrefix(sb.toString());
}
sb.write('разворачивается цепь из ${words.length} взаимосвязанных архетипов. ');
sb.write('Исток ситуации заложен в знаке #${first.formattedSessionNumber} «${first.word.toUpperCase()}» — ${first.symbolicMeaning.toLowerCase()} ');
sb.write('цепь из ${words.length} знаков. Начало — «${first.word.toUpperCase()}»: ${first.symbolicMeaning} ');
if (words.length > 2) {
final middleWords = words.sublist(1, words.length - 1);
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' и ');
final midMeanings = middleWords.map((w) => w.symbolicMeaning).join(' ');
sb.write('Фаза перехода и испытаний проходит через $midNames, где $midMeanings ');
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}»').join(', ');
sb.write('Переход через $midNames. ');
}
sb.write('Кульминация и запечатывание исхода лежат в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} ');
sb.write('Оракул возвещает: путь открыт через принятие скрытых уроков и решительное действие.');
sb.write('Исход держит «${last.word.toUpperCase()}»: ${last.symbolicMeaning} ');
sb.write('Шаг: примите урок начала и сделайте одно действие в сторону исхода.');
return sb.toString();
return _withPersonaPrefix(sb.toString());
}
String _withPersonaPrefix(String body) {
switch (persona.id) {
case 'orc':
return 'Слушай. $body';
case 'techpriest':
return 'Диагностика. $body';
case 'analyst':
return 'Рабочая гипотеза. $body';
default:
return body;
}
}
String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) {
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/interpreter_persona.dart';
import '../secrets.dart';
class SettingsRepository extends ChangeNotifier {
static final SettingsRepository _instance = SettingsRepository._internal();
factory SettingsRepository() => _instance;
SettingsRepository._internal();
static const _kPersonaKey = 'oracle_interpreter_persona_v1';
static const _kRandomOrgKey = 'oracle_random_org_api_key_v1';
String _personaId = InterpreterPersona.defaultId;
String _randomOrgApiKey = '';
bool _ready = false;
bool get isReady => _ready;
String get personaId => _personaId;
InterpreterPersona get persona => InterpreterPersona.byId(_personaId);
String get randomOrgApiKey => _randomOrgApiKey;
bool get hasRandomOrgApiKey => _randomOrgApiKey.trim().isNotEmpty;
String get randomOrgApiKeyMasked {
final key = _randomOrgApiKey.trim();
if (key.isEmpty) return '';
if (key.length <= 8) return '••••';
return '${key.substring(0, 4)}••••${key.substring(key.length - 4)}';
}
Future<void> init() async {
try {
final prefs = await SharedPreferences.getInstance();
_personaId = prefs.getString(_kPersonaKey) ?? InterpreterPersona.defaultId;
_randomOrgApiKey = prefs.getString(_kRandomOrgKey) ?? '';
if (_randomOrgApiKey.trim().isEmpty) {
const fromEnv = String.fromEnvironment(
'RANDOM_ORG_API_KEY',
defaultValue: '',
);
final seeded = fromEnv.isNotEmpty ? fromEnv : kLocalRandomOrgApiKey;
if (seeded.trim().isNotEmpty) {
_randomOrgApiKey = seeded.trim();
await prefs.setString(_kRandomOrgKey, _randomOrgApiKey);
}
}
} catch (_) {
_personaId = InterpreterPersona.defaultId;
if (_randomOrgApiKey.isEmpty) {
_randomOrgApiKey = kLocalRandomOrgApiKey.trim();
}
}
_ready = true;
notifyListeners();
}
Future<void> setPersonaId(String id) async {
final next = InterpreterPersona.byId(id).id;
if (next == _personaId && _ready) return;
_personaId = next;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kPersonaKey, next);
} catch (_) {}
}
Future<void> setRandomOrgApiKey(String key) async {
final next = key.trim();
if (next == _randomOrgApiKey && _ready) return;
_randomOrgApiKey = next;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
if (next.isEmpty) {
await prefs.remove(_kRandomOrgKey);
} else {
await prefs.setString(_kRandomOrgKey, next);
}
} catch (_) {}
}
}
+344
View File
@@ -0,0 +1,344 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'settings_repository.dart';
/// Where a d10 roll came from.
enum RandomSource {
/// Atmospheric noise via RANDOM.ORG.
randomOrg,
/// OS CSPRNG. Used offline or when RANDOM.ORG is unavailable.
local,
}
class DiceRoll {
final List<int> digits;
final RandomSource source;
const DiceRoll(this.digits, this.source);
int get asNumber {
var n = 0;
for (final d in digits) {
n = n * 10 + d;
}
return n;
}
}
class RangeShuffle {
final List<int> order;
final RandomSource source;
const RangeShuffle(this.order, this.source);
}
/// True random for cleromancy rolls.
///
/// Order of attempts:
/// 1. Official JSON-RPC at api.random.org — if an API key is set
/// (settings, `--dart-define=RANDOM_ORG_API_KEY=...`, or lib/secrets.dart)
/// 2. Legacy HTTP integer API — no key, quota by IP
/// 3. [Random.secure] — always available offline
class TrueRandomService {
TrueRandomService({
http.Client? client,
String? apiKey,
this.timeout = const Duration(milliseconds: 1200),
this.minInterval = const Duration(milliseconds: 1100),
}) : _client = client ?? http.Client(),
_apiKeyOverride = apiKey;
static final TrueRandomService instance = TrueRandomService();
static const _userAgent = 'Oracle999/1.0 (Flutter; cleromancy client)';
final http.Client _client;
final String? _apiKeyOverride;
final Duration timeout;
final Duration minInterval;
final _secure = Random.secure();
List<int>? _pool;
Future<void>? _inflight;
DateTime? _lastRequest;
String get effectiveApiKey {
final override = _apiKeyOverride;
if (override != null) return override.trim();
return SettingsRepository().randomOrgApiKey.trim();
}
bool get hasApiKey => effectiveApiKey.isNotEmpty;
void invalidatePool() {
_pool = null;
}
/// Warm the pool so the next roll does not wait on the network.
void prefetch({int count = 3}) {
if (_pool != null || _inflight != null) return;
_inflight = () async {
try {
_pool = await _fetch(count);
} catch (_) {}
}().whenComplete(() {
_inflight = null;
});
}
Future<DiceRoll> take(int count) async {
if (_pool != null && _pool!.length >= count) {
final out = _pool!.sublist(0, count);
_pool = null;
prefetch(count: count);
return DiceRoll(out, RandomSource.randomOrg);
}
if (_inflight != null) {
try {
await _inflight!.timeout(const Duration(milliseconds: 800));
} catch (_) {}
if (_pool != null && _pool!.length >= count) {
final out = _pool!.sublist(0, count);
_pool = null;
prefetch(count: count);
return DiceRoll(out, RandomSource.randomOrg);
}
}
try {
final live = await _fetch(count).timeout(timeout);
prefetch(count: count);
return DiceRoll(live, RandomSource.randomOrg);
} catch (_) {
prefetch(count: count);
return DiceRoll(_localDigits(count), RandomSource.local);
}
}
List<int> localDigits(int count) => _localDigits(count);
List<int> _localDigits(int count) =>
List<int>.generate(count, (_) => _secure.nextInt(10));
/// Permutation of `[min, max]` inclusive. Used for the daily word matrix.
Future<RangeShuffle> permutation({
required int min,
required int max,
Duration timeout = const Duration(seconds: 10),
}) async {
if (max < min) {
throw ArgumentError.value(max, 'max', 'must be >= min');
}
try {
await _throttle();
final values = hasApiKey
? await _fetchJsonRpcIntegers(
n: max - min + 1,
min: min,
max: max,
replacement: false,
requestTimeout: timeout,
)
: await _fetchHttpSequence(
min: min,
max: max,
requestTimeout: timeout,
);
if (!isPermutation(values, min, max)) {
throw const FormatException('RANDOM.ORG sequence is not a permutation');
}
return RangeShuffle(values, RandomSource.randomOrg);
} catch (_) {
return RangeShuffle(localPermutation(min, max), RandomSource.local);
}
}
List<int> localPermutation(int min, int max) {
final list = [for (var i = min; i <= max; i++) i];
for (var i = list.length - 1; i > 0; i--) {
final n = _secure.nextInt(i + 1);
final tmp = list[i];
list[i] = list[n];
list[n] = tmp;
}
return list;
}
Future<List<int>> _fetch(int count) async {
await _throttle();
if (hasApiKey) {
return _fetchJsonRpcIntegers(
n: count,
min: 0,
max: 9,
replacement: true,
);
}
return _fetchHttpIntegers(count);
}
Future<void> _throttle() async {
final last = _lastRequest;
if (last != null && minInterval > Duration.zero) {
final wait = minInterval - DateTime.now().difference(last);
if (wait > Duration.zero) {
await Future<void>.delayed(wait);
}
}
_lastRequest = DateTime.now();
}
Future<List<int>> _fetchHttpIntegers(int count) async {
final uri = Uri.https('www.random.org', '/integers/', {
'num': '$count',
'min': '0',
'max': '9',
'col': '1',
'base': '10',
'format': 'plain',
'rnd': 'new',
});
final response = await _client
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'text/plain'})
.timeout(timeout);
if (response.statusCode != 200) {
throw StateError('RANDOM.ORG HTTP ${response.statusCode}');
}
return parseRandomOrgPlain(response.body, count);
}
Future<List<int>> _fetchJsonRpcIntegers({
required int n,
required int min,
required int max,
required bool replacement,
Duration? requestTimeout,
}) async {
final response = await _client
.post(
Uri.parse('https://api.random.org/json-rpc/4/invoke'),
headers: {
'User-Agent': _userAgent,
'Content-Type': 'application/json',
},
body: jsonEncode({
'jsonrpc': '2.0',
'method': 'generateIntegers',
'params': {
'apiKey': effectiveApiKey,
'n': n,
'min': min,
'max': max,
'replacement': replacement,
},
'id': 1,
}),
)
.timeout(requestTimeout ?? timeout);
if (response.statusCode != 200) {
throw StateError('RANDOM.ORG JSON-RPC HTTP ${response.statusCode}');
}
return parseRandomOrgJsonRpc(response.body, n, min: min, max: max);
}
Future<List<int>> _fetchHttpSequence({
required int min,
required int max,
Duration? requestTimeout,
}) async {
final uri = Uri.https('www.random.org', '/sequences/', {
'min': '$min',
'max': '$max',
'col': '1',
'format': 'plain',
'rnd': 'new',
});
final response = await _client
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'text/plain'})
.timeout(requestTimeout ?? timeout);
if (response.statusCode != 200) {
throw StateError('RANDOM.ORG sequence HTTP ${response.statusCode}');
}
return parseRandomOrgSequence(response.body, min, max);
}
}
List<int> parseRandomOrgPlain(String body, int count) {
final trimmed = body.trim();
if (trimmed.startsWith('Error:')) {
throw FormatException(trimmed);
}
final nums = trimmed
.split(RegExp(r'\s+'))
.where((s) => s.isNotEmpty)
.map(int.parse)
.toList();
if (nums.length < count || nums.any((n) => n < 0 || n > 9)) {
throw FormatException('bad RANDOM.ORG integers: $body');
}
return nums.sublist(0, count);
}
List<int> parseRandomOrgJsonRpc(
String body,
int count, {
int min = 0,
int max = 9,
}) {
final map = jsonDecode(body);
if (map is! Map) {
throw const FormatException('RANDOM.ORG JSON-RPC: expected object');
}
final error = map['error'];
if (error != null) {
throw FormatException('RANDOM.ORG JSON-RPC error: $error');
}
final result = map['result'];
if (result is! Map) {
throw const FormatException('RANDOM.ORG JSON-RPC: missing result');
}
final random = result['random'];
if (random is! Map) {
throw const FormatException('RANDOM.ORG JSON-RPC: missing random');
}
final data = random['data'];
if (data is! List) {
throw const FormatException('RANDOM.ORG JSON-RPC: missing data');
}
final nums = data.map((e) => (e as num).toInt()).toList();
if (nums.length < count || nums.any((n) => n < min || n > max)) {
throw FormatException('bad RANDOM.ORG JSON data: $data');
}
return nums.sublist(0, count);
}
List<int> parseRandomOrgSequence(String body, int min, int max) {
final trimmed = body.trim();
if (trimmed.startsWith('Error:')) {
throw FormatException(trimmed);
}
final nums = trimmed
.split(RegExp(r'\s+'))
.where((s) => s.isNotEmpty)
.map(int.parse)
.toList();
if (!isPermutation(nums, min, max)) {
throw FormatException('bad RANDOM.ORG sequence: $body');
}
return nums;
}
bool isPermutation(List<int> values, int min, int max) {
final expected = max - min + 1;
if (values.length != expected) return false;
final seen = <int>{};
for (final v in values) {
if (v < min || v > max || !seen.add(v)) return false;
}
return seen.length == expected;
}
+90 -20
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../data/words_data.dart';
import '../models/history_entry_model.dart';
import '../models/word_model.dart';
import 'true_random_service.dart';
class WordsRepository {
static final WordsRepository _instance = WordsRepository._internal();
@@ -10,43 +11,112 @@ class WordsRepository {
WordsRepository._internal();
static const String _kHistoryKey = 'oracle_999_sessions_history_v2';
static const String _kMappingDateKey = 'oracle_daily_mapping_date_v1';
static const String _kMappingOrderKey = 'oracle_daily_mapping_order_v1';
static const String _kMappingSourceKey = 'oracle_daily_mapping_source_v1';
List<WordItem> _sessionWords = [];
final List<OracleSession> _sessions = [];
bool _useRemoteShuffle = true;
String? _dailyMappingDate;
RandomSource _dailyMappingSource = RandomSource.local;
List<WordItem> get allWords => List.unmodifiable(_sessionWords);
List<OracleSession> get sessions => List.unmodifiable(_sessions);
List<OracleSession> get history => sessions; // Alias for compatibility
int get totalWords => _sessionWords.length;
String? get dailyMappingDate => _dailyMappingDate;
RandomSource get dailyMappingSource => _dailyMappingSource;
Future<void> init() async {
// 1. Initialize random session mapping
_initializeSessionMapping();
// 2. Load stored session history
Future<void> init({bool remoteDailyShuffle = true}) async {
_useRemoteShuffle = remoteDailyShuffle;
await _restoreOrFetchDailyMapping(force: false);
await _loadHistoryFromPrefs();
}
void _initializeSessionMapping() {
final list = List<WordItem>.from(kRussianTop1000Words);
final random = Random();
Future<RandomSource> reshuffleSessionNumbers() {
return _restoreOrFetchDailyMapping(force: true);
}
// Fisher-Yates shuffle
for (int i = list.length - 1; i > 0; i--) {
int n = random.nextInt(i + 1);
final temp = list[i];
list[i] = list[n];
list[n] = temp;
Future<RandomSource> _restoreOrFetchDailyMapping({required bool force}) async {
final today = utcDateStamp();
if (!force) {
final restored = await _loadPersistedMapping(today);
if (restored) return _dailyMappingSource;
}
_sessionWords = [];
for (int i = 0; i < list.length; i++) {
_sessionWords.add(list[i].copyWith(sessionNumber: i));
final lastId = kRussianTop1000Words.length - 1;
RangeShuffle shuffle;
if (_useRemoteShuffle) {
shuffle = await TrueRandomService.instance.permutation(
min: 0,
max: lastId,
);
} else {
shuffle = RangeShuffle(
TrueRandomService.instance.localPermutation(0, lastId),
RandomSource.local,
);
}
_applyIndexOrder(shuffle.order);
_dailyMappingDate = today;
_dailyMappingSource = shuffle.source;
await _persistMapping(today, shuffle.order, shuffle.source);
return shuffle.source;
}
void _applyIndexOrder(List<int> order) {
_sessionWords = [
for (var i = 0; i < order.length; i++)
kRussianTop1000Words[order[i]].copyWith(sessionNumber: i),
];
}
Future<bool> _loadPersistedMapping(String today) async {
try {
final prefs = await SharedPreferences.getInstance();
final date = prefs.getString(_kMappingDateKey);
final stored = prefs.getStringList(_kMappingOrderKey);
if (date != today || stored == null) return false;
final order = stored.map(int.parse).toList();
if (!isPermutation(order, 0, kRussianTop1000Words.length - 1)) {
return false;
}
_applyIndexOrder(order);
_dailyMappingDate = date;
final sourceName = prefs.getString(_kMappingSourceKey);
_dailyMappingSource = sourceName == RandomSource.randomOrg.name
? RandomSource.randomOrg
: RandomSource.local;
return true;
} catch (_) {
return false;
}
}
void reshuffleSessionNumbers() {
_initializeSessionMapping();
Future<void> _persistMapping(
String date,
List<int> order,
RandomSource source,
) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kMappingDateKey, date);
await prefs.setStringList(
_kMappingOrderKey,
order.map((e) => e.toString()).toList(),
);
await prefs.setString(_kMappingSourceKey, source.name);
} catch (_) {}
}
static String utcDateStamp([DateTime? now]) {
final d = (now ?? DateTime.now()).toUtc();
final y = d.year.toString().padLeft(4, '0');
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '$y-$m-$day';
}
WordItem? getWordBySessionNumber(int number) {
@@ -55,7 +125,7 @@ class WordsRepository {
}
WordItem getRandomWord() {
final random = Random();
final random = Random.secure();
final idx = random.nextInt(_sessionWords.length);
return _sessionWords[idx];
}
+82 -32
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/true_random_service.dart';
import 'gradient_star_badge.dart';
class D10DiceRollerWidget extends StatefulWidget {
@@ -23,63 +24,108 @@ class D10DiceRollerWidget extends StatefulWidget {
}
class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
final _random = Random();
final _flicker = Random.secure();
Timer? _rollTimer;
int? _animatedDigit0;
int? _animatedDigit1;
int? _animatedDigit2;
bool _internalRolling = false;
RandomSource? _lastSource;
void triggerRollAnimation() {
@override
void initState() {
super.initState();
TrueRandomService.instance.prefetch();
}
Future<void> triggerRollAnimation() async {
if (_internalRolling) return;
setState(() {
_internalRolling = true;
_animatedDigit0 = _random.nextInt(10);
_animatedDigit1 = _random.nextInt(10);
_animatedDigit2 = _random.nextInt(10);
_animatedDigit0 = _flicker.nextInt(10);
_animatedDigit1 = _flicker.nextInt(10);
_animatedDigit2 = _flicker.nextInt(10);
});
final target0 = _random.nextInt(10);
final target1 = _random.nextInt(10);
final target2 = _random.nextInt(10);
// Local CSPRNG is the fallback. RANDOM.ORG replaces all three faces
// together if it arrives before the first slot locks (up to ~960ms).
var target0 = _flicker.nextInt(10);
var target1 = _flicker.nextInt(10);
var target2 = _flicker.nextInt(10);
var source = RandomSource.local;
DiceRoll? remoteRoll;
int ticks = 0;
unawaited(
TrueRandomService.instance.take(3).then((roll) {
remoteRoll = roll;
}, onError: (Object _, StackTrace _) {}),
);
var locked0 = false;
var locked1 = false;
var locked2 = false;
var lock0At = 10;
var lock1At = 18;
var lock2At = 26;
var ticks = 0;
const interval = Duration(milliseconds: 60);
_rollTimer?.cancel();
_rollTimer = Timer.periodic(interval, (timer) {
if (!mounted) {
timer.cancel();
return;
}
ticks++;
// Slot 0 stops after 10 ticks (~600ms)
if (ticks < 10) {
setState(() => _animatedDigit0 = _random.nextInt(10));
} else if (ticks == 10) {
setState(() => _animatedDigit0 = target0);
HapticFeedback.mediumImpact();
if (!locked0) {
final ready = remoteRoll != null && remoteRoll!.digits.length == 3;
if (ticks >= 10 && (ready || ticks >= 16)) {
if (ready) {
target0 = remoteRoll!.digits[0];
target1 = remoteRoll!.digits[1];
target2 = remoteRoll!.digits[2];
source = remoteRoll!.source;
}
locked0 = true;
lock0At = ticks;
lock1At = lock0At + 8;
lock2At = lock1At + 8;
setState(() => _animatedDigit0 = target0);
HapticFeedback.mediumImpact();
} else {
setState(() => _animatedDigit0 = _flicker.nextInt(10));
}
}
// Slot 1 stops after 18 ticks (~1080ms)
if (ticks < 18) {
setState(() => _animatedDigit1 = _random.nextInt(10));
} else if (ticks == 18) {
setState(() => _animatedDigit1 = target1);
HapticFeedback.mediumImpact();
if (!locked1) {
if (locked0 && ticks >= lock1At) {
locked1 = true;
setState(() => _animatedDigit1 = target1);
HapticFeedback.mediumImpact();
} else {
setState(() => _animatedDigit1 = _flicker.nextInt(10));
}
}
// Slot 2 stops after 26 ticks (~1560ms)
if (ticks < 26) {
setState(() => _animatedDigit2 = _random.nextInt(10));
} else if (ticks == 26) {
setState(() => _animatedDigit2 = target2);
HapticFeedback.heavyImpact();
if (!locked2) {
if (locked1 && ticks >= lock2At) {
locked2 = true;
setState(() => _animatedDigit2 = target2);
HapticFeedback.heavyImpact();
} else {
setState(() => _animatedDigit2 = _flicker.nextInt(10));
}
}
if (ticks >= 28) {
if (locked2 && ticks >= lock2At + 2) {
timer.cancel();
setState(() => _internalRolling = false);
final finalNumber = target0 * 100 + target1 * 10 + target2;
widget.onRollCompleted(finalNumber);
setState(() {
_internalRolling = false;
_lastSource = source;
});
widget.onRollCompleted(target0 * 100 + target1 * 10 + target2);
}
});
}
@@ -141,7 +187,11 @@ class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
Text(
_internalRolling
? 'Бросок...'
: (widget.digits.length == 3 ? 'Зафиксировано' : '3 цифры'),
: (widget.digits.length == 3
? (_lastSource == RandomSource.randomOrg
? 'RANDOM.ORG'
: 'Зафиксировано')
: '3 цифры'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
+8 -8
View File
@@ -260,10 +260,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -276,10 +276,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
package_config:
dependency: transitive
description:
@@ -441,10 +441,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -465,10 +465,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
vm_service:
dependency: transitive
description:
+33
View File
@@ -64,14 +64,21 @@ category_map = {
'praed': 'Состояние бытия',
}
# Live dataset is rebuilt by scripts/rebuild_oracle_lexicon.py (exactly 1000, grouped).
# Exhaustive filter of modern, journalistic, socio-political, economic, administrative and mundane noise
banned_mundane_words = {
# Proper names / toponyms / abbreviations — never in the oracle
'александр', 'америка', 'андрей', 'борис', 'виктор', 'владимир', 'европа',
'иван', 'иванович', 'игорь', 'михаил', 'москва', 'николай', 'россия', 'рф',
'сергей', 'ссср', 'сталин', 'сша', 'первый', 'второй', 'третий',
# Domestic & mundane duplicates
'мама', 'папа', 'немец', 'журнал', 'газета', 'билет', 'метр', 'тонна',
'километр', 'килограмм', 'автобус', 'телевизор', 'телефон', 'квартира',
'кабинет', 'документ', 'паспорт', 'секретарь', 'статья', 'стол', 'стул',
'окно', 'магазин', 'поезд', 'машина', 'автомобиль', 'трамвай', 'вокзал',
'аэропорт', 'комната', 'коридор', 'этаж', 'завод', 'фабрика', 'склад',
'губа', 'карман', 'господин', 'самолет', 'фильм', 'школа',
# Bureaucratic / Political / State apparatus
'пункт', 'государство', 'правительство', 'министр', 'депутат', 'советский',
@@ -99,6 +106,32 @@ banned_mundane_words = {
'основной', 'общий', 'главный', 'крупный', 'важный', 'значительный',
'особый', 'подобный', 'каждый', 'любой', 'всякий', 'некоторый',
'никакой', 'несколько', 'результат', 'условие', 'ситуация',
# Institutional / journalistic nouns culled from the oracle
'автор', 'анализ', 'армия', 'база', 'безопасность', 'большинство', 'бумага',
'вариант', 'возможность', 'возраст', 'врач', 'время', 'выбор', 'генерал',
'глава', 'группа', 'данные', 'действие', 'дело', 'директор', 'доктор',
'задача', 'здание', 'значение', 'изменение', 'использование', 'исследование',
'качество', 'класс', 'количество', 'команда', 'комплекс', 'контроль',
'культура', 'курс', 'литература', 'масса', 'материал', 'метод', 'минута',
'мнение', 'модель', 'население', 'наука', 'начальник', 'неделя',
'необходимость', 'номер', 'образование', 'обстоятельство', 'общество',
'объем', 'операция', 'организация', 'оценка', 'очередь', 'период',
'писатель', 'план', 'площадь', 'повод', 'подготовка', 'позиция', 'политика',
'положение', 'понятие', 'практика', 'предложение', 'предмет', 'председатель',
'представитель', 'представление', 'президент', 'пример', 'проведение',
'продукт', 'производство', 'работа', 'разговор', 'размер', 'регион',
'республика', 'реформа', 'роль', 'ряд', 'содержание', 'соответствие',
'состав', 'спектакль', 'способ', 'средство', 'срок', 'степень', 'страна',
'строительство', 'текст', 'тема', 'техника', 'технология', 'тип', 'товарищ',
'требование', 'услуга', 'участие', 'участник', 'участок', 'факт',
'федерация', 'хозяйство', 'член',
'американский', 'боевой', 'больший', 'бывший', 'военный', 'городской',
'детский', 'должен', 'единственный', 'известный', 'местный', 'мировой',
'московский', 'народный', 'небольшой', 'немецкий', 'нужный', 'нынешний',
'обычный', 'отдельный', 'очередной', 'рабочий', 'российский', 'русский',
'собственный', 'современный', 'способный', 'средний', 'старший', 'судебный',
'человеческий',
}
# Sacred Archetypes, Polarities, and Divination Symbols
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Rebuild the Oracle 999 lexicon: drop names/bureaucracy, keep function words, expand the sacred core to exactly 1000."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "lib/data/words_data.dart"
OUT = SRC
CAT_ORDER = [
"Образ / Архетип",
"Космический Знак",
"Сакральный Символ",
"Священный Предмет",
"Сакральное Место",
"Тотем / Архетип",
"Архетип Человека",
"Действие / Трансформация",
"Свойство / Качество",
"Вектор / Обстоятельство",
"Связь / Направление",
"Направление",
"Определение",
"Узел / Отношение",
"Импульс / Оттенок",
"Фокус сознания",
"Числовой код",
]
CORE_ORDER = [
"огонь", "вода", "воздух", "земля", "свет", "тьма", "день", "ночь", "утро", "вечер",
"север", "юг", "восток", "запад", "небо", "бездна", "верх", "низ", "высота", "глубина",
"порядок", "хаос", "жизнь", "смерть", "рождение", "угасание", "начало", "конец",
"добро", "зло", "истина", "ложь", "сила", "слабость", "любовь", "ненависть", "мир", "война",
"свобода", "плен", "дух", "тело", "явь", "сон", "память", "забвение", "пустота", "полнота",
"вход", "выход", "друг", "враг", "чистота", "скверна", "дар", "жертва", "тайна", "откровение",
"тишина", "зов", "ключ", "замок", "мать", "отец", "мудрец", "безумец",
"звезда", "солнце", "луна", "комета", "затмение", "рассвет", "закат", "зенит", "эфир", "вечность",
"судьба", "рок", "знамение", "пророчество",
"алтарь", "святилище", "храм", "купель", "чаша", "посох", "меч", "щит", "корона", "цепь",
"кольцо", "свиток", "зеркало", "свеча", "нить", "печать", "завеса", "колокол", "факел", "кристалл",
"океан", "пустыня", "пещера", "лабиринт", "скала", "гора", "долина", "остров", "источник", "родник",
"водопад", "перекресток", "порог", "врата", "мост", "башня", "сад", "роща",
"древо", "корень", "семя", "плод", "цветок", "роза", "лотос",
"змей", "орел", "волк", "ворон", "лев", "сова", "лебедь", "дракон", "феникс",
"странник", "хранитель", "воин", "целитель", "пророк", "вестник", "страж", "царь", "жрец", "маг",
"отшельник", "творец", "спутник",
]
CORE = set(CORE_ORDER)
FUNC_POS = {
"предлог", "союз", "частица", "местоимение", "местоименное прил.", "наречие-местоимение",
}
DROP = {
# names / toponyms / abbreviations
"александр", "америка", "андрей", "борис", "виктор", "владимир", "европа", "иван",
"иванович", "игорь", "михаил", "москва", "николай", "россия", "рф", "сергей", "ссср",
"сталин", "сша",
# ordinals
"первый", "второй", "третий",
# bureaucratic / mundane nouns
"автор", "анализ", "армия", "база", "безопасность", "большинство", "бумага", "вариант",
"возможность", "возраст", "врач", "время", "выбор", "генерал", "глава", "группа", "губа",
"данные", "действие", "дело", "директор", "доктор", "задача", "здание", "значение",
"изменение", "использование", "исследование", "карман", "качество", "класс", "количество",
"команда", "комплекс", "контроль", "культура", "курс", "литература", "масса", "материал",
"метод", "минута", "мнение", "модель", "население", "наука", "начальник", "неделя",
"необходимость", "номер", "образование", "обстоятельство", "общество", "объем", "операция",
"организация", "оценка", "очередь", "период", "писатель", "план", "площадь", "повод",
"подготовка", "позиция", "политика", "положение", "понятие", "практика", "предложение",
"предмет", "председатель", "представитель", "представление", "президент", "пример",
"проведение", "продукт", "производство", "работа", "разговор", "размер", "регион",
"республика", "реформа", "роль", "ряд", "самолет", "содержание", "соответствие", "состав",
"спектакль", "способ", "средство", "срок", "степень", "страна", "строительство", "текст",
"тема", "техника", "технология", "тип", "товарищ", "требование", "услуга", "участие",
"участник", "участок", "факт", "федерация", "фильм", "хозяйство", "член", "школа",
"господин",
# journalistic adjectives
"американский", "боевой", "больший", "бывший", "военный", "городской", "детский", "должен",
"единственный", "известный", "местный", "мировой", "московский", "народный", "небольшой",
"немецкий", "нужный", "нынешний", "обычный", "отдельный", "очередной", "рабочий",
"российский", "русский", "собственный", "современный", "способный", "средний", "старший",
"судебный", "человеческий",
# bureaucratic verbs
"заявить", "использовать", "иметься", "называться", "отметить", "относиться", "позволять",
"получаться", "получиться", "предлагать", "представить", "представлять", "привести",
"принимать", "приходиться", "провести", "проводить", "произойти", "происходить", "работать",
"следовать", "составлять", "состоять", "считать", "требовать", "удаться", "установить",
"являться", "заниматься", "определить", "оказаться", "оказываться", "приехать", "позвонить",
"купить", "сообщить", "составить", "выступать", "получать",
# meta / journalistic adverbs
"весьма", "видимо", "видно", "вовсе", "вполне", "впрочем", "далее", "действительно",
"довольно", "достаточно", "значит", "известно", "кажется", "конечно", "кстати", "менее",
"может", "наиболее", "например", "необходимо", "несмотря", "обычно", "одновременно",
"особенно", "полностью", "практически", "совершенно", "также",
}
# word, category, description — exactly 236 universal core symbols
NEW_CORE = [
# --- Образ / Архетип (80) ---
("лёд", "Образ / Архетип", "Застывший поток, остановка движения, хрупкая прозрачность сохранённой памяти."),
("пламя", "Образ / Архетип", "Живой огонь преображения, неудержимый подъём, свет, требующий пищи."),
("искра", "Образ / Архетип", "Первый толчок замысла, крошечное начало великого горения."),
("пепел", "Образ / Архетип", "Остаток сожжённого, смирение формы, почва для нового ростка."),
("дым", "Образ / Архетип", "След исчезнувшего, молитва, уходящая в небо, завеса между мирами."),
("пар", "Образ / Архетип", "Граница воды и воздуха, переход состояний, невидимая сила нагрева."),
("тень", "Образ / Архетип", "Спутник света, сокрытая сторона души, то, без чего нет объёма."),
("луч", "Образ / Архетип", "Направленный свет, пронзающий тьму, избранный путь озарения."),
("сияние", "Образ / Архетип", "Избыток света, слава присутствия, излучение без источника-огня."),
("мрак", "Образ / Архетип", "Плотная тьма без звёзд, испытание слепотой, лоно невидимого."),
("сумерки", "Образ / Архетип", "Порог дня и ночи, час двусмысленности, время духов и знаков."),
("полдень", "Образ / Архетип", "Пик явленности, беспощадная ясность, тень, сжатая под ногами."),
("полночь", "Образ / Архетип", "Сердце ночи, точка перелома суток, час глубоких решений."),
("зима", "Образ / Архетип", "Сон земли, испытание холодом, время корней и внутреннего огня."),
("весна", "Образ / Архетип", "Пробуждение соков, нежность начала, возврат жизненной силы."),
("осень", "Образ / Архетип", "Сбор плодов, золотое увядание, мудрость отпускания."),
("молния", "Образ / Архетип", "Мгновенный удар неба, озарение-разрыв, воля без отлагательства."),
("гром", "Образ / Архетип", "Голос небес после вспышки, предупреждение, сотрясение основ."),
("дождь", "Образ / Архетип", "Благодатное омовение, слёзы неба, питание иссохшей земли."),
("туман", "Образ / Архетип", "Скрывающий покров, растворение границ, время не доверять глазам."),
("роса", "Образ / Архетип", "Тихий дар утра, чистота, собранная без грозы."),
("иней", "Образ / Архетип", "Хрупкий узор холода, красота, тающая от дыхания."),
("буря", "Образ / Архетип", "Ярость стихий, очистительный слом, проверка, крепки ли корни."),
("волна", "Образ / Архетип", "Ритм океана, подъём и спад, сила, возвращающаяся к берегу."),
("прилив", "Образ / Архетип", "Нарастание сил, прибытие дара, заполнение пустот."),
("отлив", "Образ / Архетип", "Отход вод, обнажение дна, время увидеть скрытое."),
("лава", "Образ / Архетип", "Расплавленная сердцевина, разрушительное творение новой земли."),
("радуга", "Образ / Архетип", "Завет после грозы, мост цветов, знак примирения стихий."),
("эхо", "Образ / Архетип", "Возвращённый голос, последствие слова, память пространства."),
("миг", "Образ / Архетип", "Атом времени, дверь, открытая лишь на вдох."),
("цикл", "Образ / Архетип", "Вечный круговорот, возврат урока, закон повторения до понимания."),
("эпоха", "Образ / Архетип", "Большой цикл судьбы, смена мирового дыхания."),
("карма", "Образ / Архетип", "Неустранимый посев дел, возвращающийся плод каждого выбора."),
("грех", "Образ / Архетип", "Нарушение внутренней правды, трещина в целостности духа."),
("вина", "Образ / Архетип", "Груз содеянного, зов к искуплению, якорь прошлого."),
("стыд", "Образ / Архетип", "Огонь обнажённости, страж границ, сигнал совести."),
("гордыня", "Образ / Архетип", "Раздутое «я», слепота высоты, падение, уже начавшееся внутри."),
("смирение", "Образ / Архетип", "Сила, преклонившая голову, податливость, в которой нет слабости."),
("отчаяние", "Образ / Архетип", "Дно колодца, точка, где остаётся только крик к Небу."),
("сомнение", "Образ / Архетип", "Трещина в вере, страж от слепоты, яд, если пьют без меры."),
("мудрость", "Образ / Архетип", "Зрение сквозь время, тихий плод прожитых ошибок."),
("справедливость", "Образ / Архетип", "Весы мира, восстановление меры, меч, не знающий лица."),
("месть", "Образ / Архетип", "Круг боли, возвращающий удар, огонь, сжигающий держащего."),
("прощение", "Образ / Архетип", "Размыкание цепи, отпускание яда, свобода обоих берегов."),
("клятва", "Образ / Архетип", "Слово, ставшее узами, печать воли, которую нельзя снять легко."),
("обет", "Образ / Архетип", "Добровольный зарок, стена и крылья избранного пути."),
("искушение", "Образ / Архетип", "Приманка тени, проверка подлинности желания."),
("искупление", "Образ / Архетип", "Цена возврата к целому, труд очищения долга."),
("посвящение", "Образ / Архетип", "Переход через порог знания, смерть старого имени."),
("чудо", "Образ / Архетип", "Вмешательство иного порядка, разрыв привычной причинности."),
("благословение", "Образ / Архетип", "Поток благой силы, рука, лежащая на судьбе."),
("проклятие", "Образ / Архетип", "Заклятый яд слова, узел, требующий расплетения."),
("яд", "Образ / Архетип", "Скрытая порча, лекарство в капле, смерть в чаше."),
("нектар", "Образ / Архетип", "Пища бессмертия, сладость истины, дар, которого нельзя требовать."),
("голод", "Образ / Архетип", "Пустота, движущая к поиску, испытание нуждой."),
("жажда", "Образ / Архетип", "Зов к источнику, сухость духа, неутолённое стремление."),
("покой", "Образ / Архетип", "Неподвижность целого, мир после бури, священная остановка."),
("тревога", "Образ / Архетип", "Сторожевой звон, предчувствие сдвига, ум без места."),
("гнев", "Образ / Архетип", "Огонь границы, сила «нет», разрушительный без узды."),
("печаль", "Образ / Архетип", "Дождь сердца, честная дань утрате, очищение через тяжесть."),
("скорбь", "Образ / Архетип", "Глубокое озеро потери, время, когда мир сужается до боли."),
("страсть", "Образ / Архетип", "Пожирающий жар желания, двигатель творения и гибели."),
("холод", "Образ / Архетип", "Отстранение, ясность без тепла, сон чувств."),
("жар", "Образ / Архетип", "Лихорадка жизни, перегрев воли, пламя, близкое к болезни."),
("изобилие", "Образ / Архетип", "Переполнение даров, рог судьбы, испытание мерой."),
("нищета", "Образ / Архетип", "Обнажённость без покровов, свобода от лишнего, урок нуля."),
("болезнь", "Образ / Архетип", "Весть тела, остановка привычного, зов к исцелению корня."),
("рана", "Образ / Архетип", "Открытый край опыта, место, где входит и свет, и яд."),
("шрам", "Образ / Архетип", "Зажившая память, знак пройденного огня, печать урока на коже."),
("безмолвие", "Образ / Архетип", "Глубже тишины, отказ от слова, слух, ставший безграничным."),
("шёпот", "Образ / Архетип", "Тайная речь, совет на ухо, сила малого звука."),
("крик", "Образ / Архетип", "Прорыв невыносимого, зов о помощи, разрыв завесы."),
("смех", "Образ / Архетип", "Освобождение сжатого, священное несерьёзное, победа над страхом."),
("плач", "Образ / Архетип", "Вода глаз, разрешение камня в груди, дождь исцеления."),
("танец", "Образ / Архетип", "Молитва тела, согласие с ритмом мира, путь без слов."),
("бдение", "Образ / Архетип", "Сторожение в ночи, отказ от сна ради смысла."),
("пост", "Образ / Архетип", "Священный голод, очищение от лишнего, заточка воли."),
("пир", "Образ / Архетип", "Праздник полноты, союз за столом, опасность избытка."),
("благодать", "Образ / Архетип", "Незаслуженный дар свыше, масло на рану мира."),
("дыхание", "Образ / Архетип", "Нить жизни, обмен с миром, первая и последняя молитва тела."),
# --- Космический Знак (14) ---
("заря", "Космический Знак", "Первый цвет неба, обещание дня, нежность начала света."),
("горизонт", "Космический Знак", "Линия невозможного касания, предел видимого, зов идти дальше."),
("небосвод", "Космический Знак", "Чаша над головой, кров судьбы, поле, по которому ходят светила."),
("созвездие", "Космический Знак", "Узор судеб на тёмном поле, союз далёких огней, дающих имя пути."),
("планета", "Космический Знак", "Странник орбиты, характер влияния, тело, держащее свой закон."),
("метеор", "Космический Знак", "Короткий огонь падения, исполнение желания, след, сгорающий в полёте."),
("аврора", "Космический Знак", "Полярное сияние духа, завеса меж мирами, танец невидимых сил."),
("надир", "Космический Знак", "Противоточка зенита, глубинная опора, низ, без которого нет верха."),
("солнцеворот", "Космический Знак", "Перелом годового колеса, возврат света или тьмы, священный канун."),
("полнолуние", "Космический Знак", "Полная чаша ночи, пик чувства, безумие и ясность одновременно."),
("новолуние", "Космический Знак", "Тёмное зерно месяца, посев втайне, сила начинать в невидимом."),
("равноденствие", "Космический Знак", "Равный шаг света и тьмы, точка меры, хрупкий космический баланс."),
("зодиак", "Космический Знак", "Пояс зверей неба, круг архетипов, через который проходит Солнце."),
("орбита", "Космический Знак", "Закон возвращения, путь вокруг центра, верность своему кругу."),
# --- Сакральный Символ (28) ---
("спираль", "Сакральный Символ", "Путь, который возвращается выше, рост через виток, живая геометрия."),
("узел", "Сакральный Символ", "Связка судеб, заклятие или союз, то, что нельзя разрубить без цены."),
("яйцо", "Сакральный Символ", "Целое до рождения, защищённый потенциал, мир, ещё не расколотый."),
("весы", "Сакральный Символ", "Мера правды, час выбора, требование не перевесить чашу."),
("якорь", "Сакральный Символ", "Верность глубине, остановка дрейфа, тяжесть, спасающая в буре."),
("крест", "Сакральный Символ", "Схождение вертикали духа и горизонтали мира, принятие полной меры."),
("руна", "Сакральный Символ", "Сжатое имя силы, знак, который действует, когда его произносят."),
("мандала", "Сакральный Символ", "Священный круг порядка, карта внутреннего космоса, целостность узора."),
("уроборос", "Сакральный Символ", "Змей, кусающий хвост, вечное самопожирание и саморождение цикла."),
("грааль", "Сакральный Символ", "Невзятая чаша духа, святыня поиска, дар лишь чистым намерениям."),
("колесо", "Сакральный Символ", "Фортуна и закон оборота, взлёт, падение и снова взлёт."),
("ось", "Сакральный Символ", "Неподвижный стержень вращения мира, точка, вокруг которой всё пляшет."),
("рог", "Сакральный Символ", "Сила зверя и изобилие, зов сбора, власть, растущая из плоти."),
("кадуцей", "Сакральный Символ", "Жезл согласия противоположностей, исцеление через сплетение змеев."),
("тотем", "Сакральный Символ", "Родовой зверь-хранитель, живой герб души, запрет и защита крови."),
("идол", "Сакральный Символ", "Застывший образ силы, опасность поклонения форме вместо сути."),
("скрижаль", "Сакральный Символ", "Высеченный закон, слово, ставшее камнем, нестираемая заповедь."),
("гримуар", "Сакральный Символ", "Книга тайных имён, опасное знание, ключ, требующий зрелости."),
("дуб", "Сакральный Символ", "Царь леса, стойкость веков, священная ось между корнем и кроной."),
("ива", "Сакральный Символ", "Плачущая гибкость, вода в дереве, сила, что гнётся и не ломается."),
("берёза", "Сакральный Символ", "Чистота северного света, обновление кожи, нежность и живучесть."),
("кедр", "Сакральный Символ", "Нетленное дерево, благовонный столп, память, не гниющая во времени."),
("кипарис", "Сакральный Символ", "Страж порога смерти, вечнозелёная скорбь, дерево иного берега."),
("плющ", "Сакральный Символ", "Верность опоре, медленное овладение, жизнь, держащаяся чужого ствола."),
("полынь", "Сакральный Символ", "Горькая трава ясности, изгнание морока, лекарство, которое не сладко."),
("папоротник", "Сакральный Символ", "Цветок Ивановой ночи, скрытый огонь в тени, удача для ищущего."),
("лавр", "Сакральный Символ", "Венок победы и поэта, слава, которую нельзя сорвать без цены."),
("олива", "Сакральный Символ", "Ветвь мира, масло света, долготерпение среди камней."),
# --- Священный Предмет (32) ---
("карта", "Священный Предмет", "Лик пути, расклад возможностей, зеркало, говорящее образами."),
("компас", "Священный Предмет", "Верность северу души, стрелка, не лгущая в тумане."),
("стрела", "Священный Предмет", "Намерение, пущенное в полёт, слово, которое уже не вернуть."),
("лук", "Священный Предмет", "Напряжённая воля, гибкая сила, готовность к точному выстрелу."),
("копьё", "Священный Предмет", "Прямая воля на расстоянии, укол правды, оружие стража порога."),
("кинжал", "Священный Предмет", "Сокровенное оружие близи, скрытое решение, клинок в рукаве судьбы."),
("топор", "Священный Предмет", "Рассекающий удар, рубка лишнего, орудие и строительства, и казни."),
("молот", "Священный Предмет", "Сила кузницы, удар, придающий форму, гром ремесла и суда."),
("верёвка", "Священный Предмет", "Связь и путы, спасение и петля, то, чем вяжут судьбы."),
("сосуд", "Священный Предмет", "Вместилище дара или яда, мера того, что вы способны удержать."),
("лампа", "Священный Предмет", "Ручной свет в темноте, знание, которое носят с собой."),
("фонарь", "Священный Предмет", "Страж ночной тропы, круг света среди неизвестного."),
("маска", "Священный Предмет", "Личина роли, защита лица, опасность забыть, кто под ней."),
("вуаль", "Священный Предмет", "Полускрытость тайны, стыдливость святыни, дымка между вами и сутью."),
("плащ", "Священный Предмет", "Покров странника, невидимость, достоинство, которое носят на плечах."),
("барабан", "Священный Предмет", "Сердцебиение ритуала, зов к сбору, ритм, подчиняющий шаг."),
("флейта", "Священный Предмет", "Голос ветра в тростнике, зачарование, зов, ведущий за собой."),
("арфа", "Священный Предмет", "Струны души, гармония сфер, музыка, примиряющая небо и землю."),
("лира", "Священный Предмет", "Оружие поэта, строй космоса, дар укрощать зверя песней."),
("жребий", "Священный Предмет", "Священный случай, кость вопроса, согласие принять выпавшее."),
("кость", "Священный Предмет", "Остов формы, память предков, оракул, брошенный на землю."),
("амулет", "Священный Предмет", "Малая стража на теле, сжатая молитва, щит, который носят скрытно."),
("талисман", "Священный Предмет", "Заряженный знак удачи, якорь намерения, вещь, держащая заклинание."),
("реликвия", "Священный Предмет", "Остаток святого присутствия, мост к предку, сила через касание."),
("кадило", "Священный Предмет", "Дым молитвы, очищение воздуха, качающийся ритм священнодействия."),
("ладан", "Священный Предмет", "Благовоние восхождения, сладкий дым, несущий слово вверх."),
("мирра", "Священный Предмет", "Горький дар погребения и царей, масло скорби и коронации."),
("котёл", "Священный Предмет", "Лоно превращений, варево судеб, место, где многое становится одним."),
("кубок", "Священный Предмет", "Чаша союза, клятва за столом, глоток, связывающий пьющих."),
("хлеб", "Священный Предмет", "Плоть земли, дар насущный, преломление, делающее чужих своими."),
("вино", "Священный Предмет", "Кровь лозы, опьянение духа, таинство радости и жертвы."),
("соль", "Священный Предмет", "Завет нетления, вкус правды, охрана от гниения и порчи."),
# --- Сакральное Место (26) ---
("колодец", "Сакральное Место", "Глубина, откуда пьют, голос снизу, опасная и живая тьма воды."),
("кладбище", "Сакральное Место", "Сад предков, порог иного берега, тишина, учащая мере жизни."),
("тропа", "Сакральное Место", "Узкий путь знающих, след, протоптанный теми, кто шёл раньше."),
("перевал", "Сакральное Место", "Высшая точка перехода, ветер двух склонов, решение идти дальше."),
("пропасть", "Сакральное Место", "Разрыв земли, испытание головокружением, край, за которым нет дна."),
("болото", "Сакральное Место", "Зыбкая середина, место застревания, опасность принять топь за путь."),
("озеро", "Сакральное Место", "Зеркало в земле, стоячая глубина, душа, смотрящая на себя."),
("ручей", "Сакральное Место", "Малый живой ток, начало реки, скромная сила, которая не иссякает."),
("маяк", "Сакральное Место", "Огонь для заблудших, одинокая башня смысла в море хаоса."),
("кузница", "Сакральное Место", "Священный жар ремесла, место, где ударом рождается форма."),
("катакомбы", "Сакральное Место", "Подземные ходы памяти, скрытая церковь, путь в темноте костей."),
("чертог", "Сакральное Место", "Чертог силы, зал присутствия, пространство, где являются властители."),
("дворец", "Сакральное Место", "Обитель порядка и соблазна власти, красота, требующая стражи."),
("хижина", "Сакральное Место", "Малый кров отшельника, простота, в которой живёт большое."),
("шатёр", "Сакральное Место", "Переносной храм странника, временный круг своего среди чужого."),
("костёр", "Сакральное Место", "Сердце ночного стана, огонь собрания, круг, отгоняющий тьму."),
("пристань", "Сакральное Место", "Место прибытия и прощания, пауза между морями пути."),
("капище", "Сакральное Место", "Языческий престол сил, открытый алтарь под небом, место договора с духами."),
("дубрава", "Сакральное Место", "Священный лес дубов, вече предков, шепчущий свод листвы."),
("чаща", "Сакральное Место", "Густая глубь леса, потерянность, испытание без ориентиров."),
("ущелье", "Сакральное Место", "Узкий ход меж скал, вынужденный путь, эхо каждого шага."),
("обрыв", "Сакральное Место", "Край, где кончается земля, требование прыжка или поворота."),
("подземелье", "Сакральное Место", "Низ мира, место испытания и клада, спуск за своей тенью."),
("очаг", "Сакральное Место", "Сердце дома, хранимый огонь рода, точка, вокруг которой собираются свои."),
("скит", "Сакральное Место", "Уединённая обитель молитвы, пустынь духа вдали от шума."),
("обитель", "Сакральное Место", "Общий дом посвящённых, устав и кров, школа тишины."),
# --- Тотем / Архетип (30) ---
("медведь", "Тотем / Архетип", "Сон и мощь, хозяин чащи, сила, которая умеет уходить в зиму."),
("лиса", "Тотем / Архетип", "Хитрость тропы, рыжий ум границы, искусство быть непойманным."),
("олень", "Тотем / Архетип", "Благородство леса, венценосные рога года, чуткость и гордый шаг."),
("конь", "Тотем / Архетип", "Ветер под седлом, верный ход судьбы, сила, несущая всадника."),
("паук", "Тотем / Архетип", "Ткач сетей, терпеливый охотник центра, узор, в котором вязнут."),
("бабочка", "Тотем / Архетип", "Душа после кокона, хрупкое преображение, красота короткого дня."),
("рыба", "Тотем / Архетип", "Молчаливая мудрость вод, добыча и спасение, жизнь в невидимой глубине."),
("кит", "Тотем / Архетип", "Левиафан глубин, память океана, огромность, поющая в бездне."),
("петух", "Тотем / Архетип", "Глашатай рассвета, страж порога ночи, гордыня и верный крик."),
("бык", "Тотем / Архетип", "Земная мощь, упрямство плодородия, сила, которую нельзя сдвинуть легко."),
("коза", "Тотем / Архетип", "Живучесть склонов, жертва и свобода, тварь, идущая там, где не ступают."),
("баран", "Тотем / Архетип", "Лоб удара, начало зодиака, упрямство первопроходца."),
("скорпион", "Тотем / Архетип", "Тайное жало, смерть и воскресение, страж того, что хранят в тени."),
("журавль", "Тотем / Архетип", "Вестник дальних стран, танец верности, птица, несущая души."),
("сокол", "Тотем / Архетип", "Удар с высоты, зрение охотника, посланник царской воли."),
("кабан", "Тотем / Архетип", "Ярость чащи, клык земли, опасная прямота неукрощённой силы."),
("тигр", "Тотем / Архетип", "Полосатый огонь джунглей, царственная опасность, красота хищника."),
("пантера", "Тотем / Архетип", "Ночная бархатная смерть, грация тени, сила, которая не шумит."),
("рысь", "Тотем / Архетип", "Тайное зрение севера, бесшумный прыжок, страж скрытых троп."),
("заяц", "Тотем / Архетип", "Робкая лунная тварь, плодовитость страха, скорость спасения."),
("черепаха", "Тотем / Архетип", "Долготерпение, дом на себе, мудрость, не спешащая к гибели."),
("пчела", "Тотем / Архетип", "Соты порядка, сладкий труд, жалящая служительница общего блага."),
("муравей", "Тотем / Архетип", "Малая сила множества, путь, которого не стыдятся, строительство снизу."),
("голубь", "Тотем / Архетип", "Вестник мира, нежность духа, птица, несущая ветвь после потопа."),
("ласточка", "Тотем / Архетип", "Возврат весны, быстрый дом под стрехой, весть, что зима кончилась."),
("филин", "Тотем / Архетип", "Ночной мудрец, круглое зрение тьмы, голос, от которого стынет кровь."),
("грифон", "Тотем / Архетип", "Страж сокровищ, союз орла и льва, тварь, не пускающая недостойных."),
("единорог", "Тотем / Архетип", "Неуловимая чистота, сила, клонящаяся лишь к невинности."),
("сфинкс", "Тотем / Архетип", "Загадка на пороге, смерть за неверный ответ, страж тайны человека."),
("дельфин", "Тотем / Архетип", "Радость глубин, спаситель утопающих, ум, играющий в волне."),
# --- Архетип Человека (26) ---
("ведьма", "Архетип Человека", "Знающая края, хозяйка трав и луны, сила, живущая вне закона толпы."),
("шаман", "Архетип Человека", "Ходок меж мирами, лекарь трещин, тот, кто платит собой за проход."),
("король", "Архетип Человека", "Бремя короны, ответственность за порядок, солнце человеческого круга."),
("королева", "Архетип Человека", "Лунная власть сердца, держащая трон изнутри, плодородие и закон дома."),
("нищий", "Архетип Человека", "Обнажённый перед миром, святой или сломленный, учитель пустых рук."),
("дитя", "Архетип Человека", "Невинное начало, открытые глаза, будущее, которое ещё можно направить."),
("старец", "Архетип Человека", "Снег на кроне, живая летопись, благословение последнего слова."),
("дева", "Архетип Человека", "Закрытый сад потенциала, чистота, ещё не отданная миру."),
("охотник", "Архетип Человека", "Выслеживание сути, терпение тропы, тот, кто кормит племя точным ударом."),
("кузнец", "Архетип Человека", "Мастер огня и железа, придающий судьбе форму ударами воли."),
("пилигрим", "Архетип Человека", "Идущий к святыне, обет дороги, жизнь, ставшая путём."),
("изгнанник", "Архетип Человека", "Вычтенный из круга, горькая свобода чужбины, урок бездомности."),
("предатель", "Архетип Человека", "Разрыв клятвы, нож в спине союза, тень, без которой не узнать верности."),
("спаситель", "Архетип Человека", "Тот, кто входит в чужую беду, свет, платящий собой за других."),
("демон", "Архетип Человека", "Испытатель воли, олицетворение страсти, сила, требующая имени и узды."),
("ангел", "Архетип Человека", "Вестник иного порядка, страж и посланник, присутствие без тяжести плоти."),
("богиня", "Архетип Человека", "Женский лик Божественного, лоно и корона, сила, дающая и забирающая."),
("палач", "Архетип Человека", "Холодный слуга предела, рука закона без гнева, конец, который кто-то должен явить."),
("судья", "Архетип Человека", "Держатель весов, слово, рассекающее спор, ответственность за меру."),
("шут", "Архетип Человека", "Священный перевёртыш, правда в колпаке, единственный, кому можно сказать королю."),
("вдова", "Архетип Человека", "Хранительница после утраты, чёрный покров силы, жизнь по ту сторону союза."),
("сирота", "Архетип Человека", "Дитя без корня, поиск рода в мире, душа, что растит себе опору сама."),
("близнец", "Архетип Человека", "Парность судьбы, зеркало рядом, урок «я» и «другой» как одно."),
("двойник", "Архетип Человека", "Тень, ходящая вашим лицом, встреча с собой в чужой плоти."),
("жрица", "Архетип Человека", "Служительница тайны, уста оракула, женщина порога меж мирами."),
("рыцарь", "Архетип Человека", "Обет служения, клинок чести, сила, преклонившаяся перед высшим."),
]
assert len(NEW_CORE) == 236, len(NEW_CORE)
NEW_WORDS = [w for w, _, _ in NEW_CORE]
assert len(NEW_WORDS) == len(set(NEW_WORDS)), "duplicate new words"
def parse_existing(text: str):
pattern = re.compile(
r"WordItem\(\s*id:\s*(\d+),\s*word:\s*\"([^\"]+)\",\s*partOfSpeech:\s*\"([^\"]+)\","
r"\s*frequency:\s*([0-9.]+),\s*category:\s*\"([^\"]+)\",\s*description:\s*\"([^\"]*)\",?\s*\)",
re.S,
)
items = []
for i, w, p, f, c, d in pattern.findall(text):
items.append({
"id": int(i),
"word": w,
"pos": p,
"freq": float(f),
"category": c,
"desc": d,
})
return items
def dart_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def fmt_freq(f: float) -> str:
if abs(f - round(f)) < 1e-9:
return f"{f:.1f}"
text = f"{f:.1f}"
return text
def main():
items = parse_existing(SRC.read_text(encoding="utf-8"))
assert len(items) == 1000, len(items)
existing_words = {it["word"] for it in items}
drop = set(DROP)
drop -= CORE
for it in items:
if it["pos"] in FUNC_POS:
drop.discard(it["word"])
missing = drop - existing_words
if missing:
raise SystemExit(f"drop not in dataset: {sorted(missing)}")
overlap = set(NEW_WORDS) & (existing_words - drop)
if overlap:
raise SystemExit(f"new words collide with kept: {sorted(overlap)}")
kept = [it for it in items if it["word"] not in drop]
assert len(kept) + len(NEW_CORE) == 1000, (len(kept), len(NEW_CORE), len(kept) + len(NEW_CORE))
by_word = {it["word"]: it for it in kept}
new_by_word = {w: (cat, desc) for w, cat, desc in NEW_CORE}
core_index = {w: i for i, w in enumerate(CORE_ORDER)}
new_index = {w: i for i, w in enumerate(NEW_WORDS)}
grouped = {cat: [] for cat in CAT_ORDER}
extra_cats = []
def bucket(word, pos, freq, category, desc, kind):
rec = {
"word": word,
"pos": pos,
"freq": freq,
"category": category,
"desc": desc,
"kind": kind, # 0 core, 1 new, 2 rest
}
if category not in grouped:
grouped[category] = []
extra_cats.append(category)
grouped[category].append(rec)
for w in CORE_ORDER:
it = by_word[w]
bucket(it["word"], it["pos"], it["freq"], it["category"], it["desc"], 0)
for w, cat, desc in NEW_CORE:
bucket(w, "существительное", 99.0, cat, desc, 1)
for it in kept:
if it["word"] in CORE:
continue
bucket(it["word"], it["pos"], it["freq"], it["category"], it["desc"], 2)
def sort_key(rec):
if rec["kind"] == 0:
return (0, core_index[rec["word"]])
if rec["kind"] == 1:
return (1, new_index[rec["word"]])
return (2, rec["word"])
ordered = []
for cat in CAT_ORDER + extra_cats:
recs = grouped.get(cat, [])
recs.sort(key=sort_key)
ordered.extend(recs)
assert len(ordered) == 1000, len(ordered)
words = [r["word"] for r in ordered]
assert len(words) == len(set(words))
lines = [
"// AUTO-GENERATED: 1000 Oracle & Divination Russian Words Dataset (000 to 999)",
"// Sacred Lexicon with Balanced Polarities, Archetypes, and Symbolic Interpretations",
"// Grouped by category. Function words kept for allegory; names/bureaucracy replaced.",
"import '../models/word_model.dart';",
"",
"const List<WordItem> kRussianTop1000Words = [",
]
for idx, rec in enumerate(ordered):
lines.append(" WordItem(")
lines.append(f" id: {idx},")
lines.append(f" word: \"{dart_escape(rec['word'])}\",")
lines.append(f" partOfSpeech: \"{dart_escape(rec['pos'])}\",")
lines.append(f" frequency: {fmt_freq(rec['freq'])},")
lines.append(f" category: \"{dart_escape(rec['category'])}\",")
lines.append(f" description: \"{dart_escape(rec['desc'])}\",")
lines.append(" ),")
lines.append("];")
lines.append("")
OUT.write_text("\n".join(lines), encoding="utf-8")
from collections import Counter
counts = Counter(r["category"] for r in ordered)
print(f"Wrote {OUT} with {len(ordered)} words")
print(f"Dropped {len(drop)}, added {len(NEW_CORE)}, kept {len(kept)}")
print("Category counts:")
for cat, n in counts.items():
print(f" {n:3d} {cat}")
leftover = sorted(w for w in DROP if w not in existing_words)
print("leftover drop (not in file)", leftover)
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:words999/models/history_entry_model.dart';
import 'package:words999/models/interpreter_persona.dart';
import 'package:words999/services/ai_oracle_service.dart';
import 'package:words999/services/settings_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await SettingsRepository().init();
await SettingsRepository().setPersonaId(InterpreterPersona.defaultId);
await SettingsRepository().setRandomOrgApiKey('');
});
test('unknown persona id falls back to clear oracle', () {
expect(InterpreterPersona.byId('nope').id, InterpreterPersona.clear.id);
});
test('settings persist the selected interpreter voice', () async {
await SettingsRepository().setPersonaId(InterpreterPersona.orc.id);
expect(SettingsRepository().persona.id, InterpreterPersona.orc.id);
SharedPreferences.setMockInitialValues({
'oracle_interpreter_persona_v1': InterpreterPersona.techpriest.id,
});
await SettingsRepository().init();
expect(SettingsRepository().persona.title, 'Техножрец');
});
test('interpretation prompt uses the selected voice and asks for a concrete step', () async {
await SettingsRepository().setPersonaId(InterpreterPersona.analyst.id);
final messages = AiOracleService().buildInterpretationMessages(
question: 'Стоит ли уходить?',
words: const [
SessionWord(
word: 'огонь',
sessionNumber: 0,
partOfSpeech: 'существительное',
category: 'Образ / Архетип',
symbolicMeaning: 'Очищающее пламя',
),
],
);
expect(messages.first.role.name, 'system');
expect(messages.first.content, contains('психоаналитик'));
expect(messages.last.content, contains('проверяемый шаг'));
expect(messages.last.content, contains('ОГОНЬ'));
expect(messages.last.content, contains('Стоит ли уходить?'));
expect(messages.first.content.toLowerCase(), isNot(contains('голос самой вечности')));
});
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:words999/services/settings_repository.dart';
import 'package:words999/services/true_random_service.dart';
void main() {
group('parseRandomOrgPlain', () {
test('reads three d10 faces', () {
expect(parseRandomOrgPlain('7\n4\n2\n', 3), [7, 4, 2]);
});
test('rejects quota / error pages', () {
expect(
() => parseRandomOrgPlain('Error: Quota exceeded', 3),
throwsFormatException,
);
});
});
group('parseRandomOrgSequence', () {
test('reads a permutation', () {
expect(parseRandomOrgSequence('2\n0\n1\n', 0, 2), [2, 0, 1]);
});
test('rejects duplicates', () {
expect(
() => parseRandomOrgSequence('0\n0\n1\n', 0, 2),
throwsFormatException,
);
});
});
group('parseRandomOrgJsonRpc', () {
test('reads generateIntegers payload', () {
const body = '''
{"jsonrpc":"2.0","result":{"random":{"data":[0,9,5]},"bitsUsed":12},"id":1}
''';
expect(parseRandomOrgJsonRpc(body, 3), [0, 9, 5]);
});
test('rejects API error objects', () {
expect(
() => parseRandomOrgJsonRpc(
'{"jsonrpc":"2.0","error":{"code":401,"message":"bad key"},"id":1}',
3,
),
throwsFormatException,
);
});
});
group('TrueRandomService', () {
test('uses RANDOM.ORG HTTP when the client succeeds', () async {
final client = MockClient((request) async {
expect(request.url.host, 'www.random.org');
expect(request.url.path, '/integers/');
expect(request.url.queryParameters['num'], '3');
expect(request.url.queryParameters['min'], '0');
expect(request.url.queryParameters['max'], '9');
return http.Response('1\n8\n3\n', 200);
});
final service = TrueRandomService(
client: client,
apiKey: '',
minInterval: Duration.zero,
);
final roll = await service.take(3);
expect(roll.digits, [1, 8, 3]);
expect(roll.source, RandomSource.randomOrg);
expect(roll.asNumber, 183);
});
test('falls back to local CSPRNG when RANDOM.ORG fails', () async {
final client = MockClient((request) async {
return http.Response('Error: Service unavailable', 503);
});
final service = TrueRandomService(
client: client,
apiKey: '',
timeout: const Duration(milliseconds: 200),
minInterval: Duration.zero,
);
final roll = await service.take(3);
expect(roll.digits.length, 3);
expect(roll.digits.every((d) => d >= 0 && d <= 9), isTrue);
expect(roll.source, RandomSource.local);
});
test('uses JSON-RPC when an API key is set', () async {
final client = MockClient((request) async {
expect(request.url.host, 'api.random.org');
expect(request.method, 'POST');
return http.Response(
'{"jsonrpc":"2.0","result":{"random":{"data":[9,0,1]}},"id":1}',
200,
);
});
final service = TrueRandomService(
client: client,
apiKey: 'dev-key',
minInterval: Duration.zero,
);
final roll = await service.take(3);
expect(roll.digits, [9, 0, 1]);
expect(roll.source, RandomSource.randomOrg);
});
test('production instance reads API key from settings, not the hashed dashboard value', () async {
SharedPreferences.setMockInitialValues({});
await SettingsRepository().init();
await SettingsRepository().setRandomOrgApiKey(
'11111111-2222-4333-8444-555555555555',
);
String? sentBody;
final client = MockClient((request) async {
sentBody = request.body;
expect(request.url.host, 'api.random.org');
return http.Response(
'{"jsonrpc":"2.0","result":{"random":{"data":[2,2,8]}},"id":1}',
200,
);
});
final service = TrueRandomService(
client: client,
minInterval: Duration.zero,
);
final roll = await service.take(3);
expect(roll.digits, [2, 2, 8]);
expect(sentBody, contains('11111111-2222-4333-8444-555555555555'));
expect(sentBody, isNot(contains('hashedApiKey')));
});
test('permutation uses generateIntegers without replacement', () async {
String? body;
final client = MockClient((request) async {
expect(request.url.host, 'api.random.org');
body = request.body;
return http.Response(
'{"jsonrpc":"2.0","result":{"random":{"data":[3,0,2,1]}},"id":1}',
200,
);
});
final service = TrueRandomService(
client: client,
apiKey: 'dev-key',
minInterval: Duration.zero,
);
final shuffle = await service.permutation(min: 0, max: 3);
expect(body, contains('"replacement":false'));
expect(body, contains('"n":4'));
expect(shuffle.source, RandomSource.randomOrg);
expect(shuffle.order, [3, 0, 2, 1]);
expect(isPermutation(shuffle.order, 0, 3), isTrue);
});
test('permutation falls back locally when RANDOM.ORG fails', () async {
final client = MockClient((request) async {
return http.Response('Error: Service unavailable', 503);
});
final service = TrueRandomService(
client: client,
apiKey: 'dev-key',
minInterval: Duration.zero,
);
final shuffle = await service.permutation(min: 0, max: 9);
expect(shuffle.source, RandomSource.local);
expect(isPermutation(shuffle.order, 0, 9), isTrue);
});
});
}
+13 -2
View File
@@ -1,19 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:words999/main.dart';
import 'package:words999/services/settings_repository.dart';
import 'package:words999/services/words_repository.dart';
void main() {
testWidgets('App smoke test - launch oracle and verify navigation', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
await WordsRepository().init();
await WordsRepository().init(remoteDailyShuffle: false);
await SettingsRepository().init();
await SettingsRepository().setRandomOrgApiKey('');
await tester.pumpWidget(const Words999App());
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// Verify app title and 2 destinations
expect(find.text('Оракул 999'), findsOneWidget);
expect(find.text('Оракул'), findsOneWidget);
expect(find.text('История'), findsOneWidget);
expect(find.text('Настройки'), findsOneWidget);
await tester.tap(find.text('Настройки'));
await tester.pumpAndSettle();
expect(find.text('Голос толкователя'), findsOneWidget);
expect(find.text('Ясный оракул'), findsOneWidget);
expect(find.text('Древний орк-оракул'), findsOneWidget);
expect(find.text('Техножрец'), findsOneWidget);
expect(find.text('Психоаналитик'), findsOneWidget);
});
}
+14 -3
View File
@@ -12,7 +12,7 @@ void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
repo = WordsRepository();
await repo.init();
await repo.init(remoteDailyShuffle: false);
});
test('Dataset contains exactly 1000 words with unique randomized session numbers 000 to 999', () {
@@ -33,14 +33,25 @@ void main() {
expect(seenSessionNumbers.length, equals(1000));
});
test('Reshuffling changes the order / mapping of words to session numbers', () {
repo.reshuffleSessionNumbers();
test('Reshuffling changes the order / mapping of words to session numbers', () async {
await repo.reshuffleSessionNumbers();
expect(repo.totalWords, equals(1000));
for (int i = 0; i < 1000; i++) {
expect(repo.getWordBySessionNumber(i)?.sessionNumber, equals(i));
}
});
test('Same UTC day reuses the persisted word matrix', () async {
final first = [
for (int i = 0; i < 8; i++) repo.getWordBySessionNumber(i)!.word,
];
await repo.init(remoteDailyShuffle: false);
expect([
for (int i = 0; i < 8; i++) repo.getWordBySessionNumber(i)!.word,
], first);
expect(repo.dailyMappingDate, WordsRepository.utcDateStamp());
});
test('Search by number string returns correct session word', () {
final results0 = repo.search('0');
expect(results0.isNotEmpty, isTrue);