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:
@@ -48,3 +48,6 @@ app.*.map.json
|
|||||||
|
|
||||||
# Local bakeoff dumps
|
# Local bakeoff dumps
|
||||||
/generated_media/
|
/generated_media/
|
||||||
|
|
||||||
|
# Local secrets (RANDOM.ORG API key, etc.)
|
||||||
|
/lib/secrets.dart
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="Оракул 999"
|
android:label="Оракул 999"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
+19
-12
@@ -15,24 +15,31 @@ subprojects {
|
|||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(":app")
|
|
||||||
}
|
|
||||||
|
|
||||||
gradle.projectsEvaluated {
|
subprojects {
|
||||||
rootProject.subprojects.forEach { proj ->
|
afterEvaluate {
|
||||||
val android = proj.extensions.findByName("android") ?: return@forEach
|
val android = extensions.findByName("android")
|
||||||
try {
|
if (android != null) {
|
||||||
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 36)
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
try {
|
try {
|
||||||
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 36)
|
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)
|
||||||
} catch (_: Throwable) {
|
} 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") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
+6928
-6927
File diff suppressed because it is too large
Load Diff
@@ -63,9 +63,7 @@ class CopilotApp extends BaseProvider {
|
|||||||
startReq.headers.addAll({
|
startReq.headers.addAll({
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'user-agent': _ua,
|
'user-agent': _ua,
|
||||||
'origin': url,
|
'X-Search-UILang': 'en-US', 'Host': 'copilot.microsoft.com',
|
||||||
'referer': '$url/',
|
|
||||||
'X-Search-UILang': 'en-US',
|
|
||||||
});
|
});
|
||||||
startReq.body = jsonEncode({
|
startReq.body = jsonEncode({
|
||||||
'timeZone': 'Europe/Moscow',
|
'timeZone': 'Europe/Moscow',
|
||||||
@@ -94,7 +92,6 @@ class CopilotApp extends BaseProvider {
|
|||||||
wsUrl,
|
wsUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': _ua,
|
'User-Agent': _ua,
|
||||||
'Origin': url,
|
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
|
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
|
||||||
@@ -239,7 +236,7 @@ class CopilotApp extends BaseProvider {
|
|||||||
List<CookieSeed> _fakeCookieSeeds() {
|
List<CookieSeed> _fakeCookieSeeds() {
|
||||||
return [
|
return [
|
||||||
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
|
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: 'MUID', value: _hex(32), domain: '.microsoft.com'),
|
||||||
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
|
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
|
||||||
CookieSeed(
|
CookieSeed(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DuckDuckGo extends WebViewBaseProvider {
|
|||||||
String get defaultModel => 'gpt-5.6-luna';
|
String get defaultModel => 'gpt-5.6-luna';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get baseUrl => 'https://duck.ai/';
|
String get baseUrl => 'https://duckduckgo.com/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
|
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class HuggingChat extends WebViewBaseProvider {
|
|||||||
if (!convRes.ok) {
|
if (!convRes.ok) {
|
||||||
return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()});
|
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;
|
const id = conv.json?.id || conv.id || conv.conversationId;
|
||||||
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
|
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'screens/history_screen.dart';
|
|||||||
import 'screens/home_search_screen.dart';
|
import 'screens/home_search_screen.dart';
|
||||||
import 'screens/oracle_chat_screen.dart';
|
import 'screens/oracle_chat_screen.dart';
|
||||||
import 'screens/provider_bakeoff_screen.dart';
|
import 'screens/provider_bakeoff_screen.dart';
|
||||||
|
import 'screens/settings_screen.dart';
|
||||||
|
import 'services/settings_repository.dart';
|
||||||
import 'services/words_repository.dart';
|
import 'services/words_repository.dart';
|
||||||
import 'widgets/gradient_star_badge.dart';
|
import 'widgets/gradient_star_badge.dart';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import 'g4f/providers/phind_ai.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await WordsRepository().init();
|
await WordsRepository().init();
|
||||||
|
await SettingsRepository().init();
|
||||||
runApp(const Words999App());
|
runApp(const Words999App());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +107,7 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
|
|||||||
final List<Widget> _screens = const [
|
final List<Widget> _screens = const [
|
||||||
HomeSearchScreen(),
|
HomeSearchScreen(),
|
||||||
HistoryScreen(),
|
HistoryScreen(),
|
||||||
|
SettingsScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -130,6 +134,11 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
|
|||||||
selectedIcon: GradientStar(size: 22),
|
selectedIcon: GradientStar(size: 22),
|
||||||
label: 'История',
|
label: 'История',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.tune_rounded),
|
||||||
|
selectedIcon: GradientStar(size: 22),
|
||||||
|
label: 'Настройки',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: GestureDetector(
|
floatingActionButton: GestureDetector(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../models/history_entry_model.dart';
|
import '../models/history_entry_model.dart';
|
||||||
import '../models/word_model.dart';
|
import '../models/word_model.dart';
|
||||||
|
import '../services/true_random_service.dart';
|
||||||
import '../services/words_repository.dart';
|
import '../services/words_repository.dart';
|
||||||
import '../widgets/cleromancy_dialog.dart';
|
import '../widgets/cleromancy_dialog.dart';
|
||||||
import '../widgets/d10_dice_roller_widget.dart';
|
import '../widgets/d10_dice_roller_widget.dart';
|
||||||
@@ -182,25 +183,47 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reshuffleMatrix() {
|
Future<void> _reshuffleMatrix() async {
|
||||||
_repo.reshuffleSessionNumbers();
|
|
||||||
_resetSession();
|
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: const Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
GradientStar(size: 20),
|
GradientStar(size: 20),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Случайный порядок 1000 чисел перетасован заново!',
|
'Запрашиваю суточную матрицу у RANDOM.ORG…',
|
||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
@@ -242,7 +265,7 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
|
|||||||
onPressed: _resetSession,
|
onPressed: _resetSession,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Перетасовать матрицу (#000..#999)',
|
tooltip: 'Новая суточная матрица через RANDOM.ORG',
|
||||||
icon: const Icon(Icons.shuffle_rounded),
|
icon: const Icon(Icons.shuffle_rounded),
|
||||||
onPressed: _reshuffleMatrix,
|
onPressed: _reshuffleMatrix,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import '../g4f/client.dart';
|
import '../g4f/client.dart';
|
||||||
import '../g4f/models.dart';
|
import '../g4f/models.dart';
|
||||||
|
import '../services/settings_repository.dart';
|
||||||
|
|
||||||
class OracleChatScreen extends StatefulWidget {
|
class OracleChatScreen extends StatefulWidget {
|
||||||
const OracleChatScreen({Key? key}) : super(key: key);
|
const OracleChatScreen({Key? key}) : super(key: key);
|
||||||
@@ -22,15 +23,20 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
|
|||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final G4FClient _client = G4FClient();
|
final G4FClient _client = G4FClient();
|
||||||
|
|
||||||
final List<ChatMessage> _messages = [
|
late final List<ChatMessage> _messages;
|
||||||
ChatMessage(
|
|
||||||
text: "Я Оракул. Мой взор проникает сквозь завесу времени. О чем ты хочешь спросить?",
|
|
||||||
isUser: false
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_messages = [
|
||||||
|
ChatMessage(
|
||||||
|
text: SettingsRepository().persona.chatGreeting,
|
||||||
|
isUser: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scrollController.hasClients) {
|
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 {
|
Future<void> _sendMessage() async {
|
||||||
final text = _controller.text.trim();
|
final text = _controller.text.trim();
|
||||||
if (text.isEmpty || _isLoading) return;
|
if (text.isEmpty || _isLoading) return;
|
||||||
@@ -64,7 +62,12 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
|
|||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
|
||||||
final apiMessages = <Message>[
|
final apiMessages = <Message>[
|
||||||
_oracleSystem,
|
Message(
|
||||||
|
role: Role.system,
|
||||||
|
content:
|
||||||
|
'${SettingsRepository().persona.systemPrompt}\n'
|
||||||
|
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
|
||||||
|
),
|
||||||
for (final m in _messages)
|
for (final m in _messages)
|
||||||
if (m.text.trim().isNotEmpty)
|
if (m.text.trim().isNotEmpty)
|
||||||
Message(
|
Message(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import '../models/history_entry_model.dart';
|
import '../models/history_entry_model.dart';
|
||||||
|
import '../models/interpreter_persona.dart';
|
||||||
import '../models/word_model.dart';
|
import '../models/word_model.dart';
|
||||||
import '../g4f/g4f.dart';
|
import '../g4f/g4f.dart';
|
||||||
|
import 'settings_repository.dart';
|
||||||
|
|
||||||
class AiOracleService {
|
class AiOracleService {
|
||||||
static final AiOracleService _instance = AiOracleService._internal();
|
static final AiOracleService _instance = AiOracleService._internal();
|
||||||
@@ -9,20 +11,23 @@ class AiOracleService {
|
|||||||
|
|
||||||
final _client = G4FClient();
|
final _client = G4FClient();
|
||||||
|
|
||||||
static const oracleSystemPrompt =
|
InterpreterPersona get persona => SettingsRepository().persona;
|
||||||
'''Ты — Великий Оракул 999, древний архетипический разум.
|
|
||||||
Твоя задача — толковать знаки судьбы (жребий). Твой стиль речи: глубокий, философский, лаконичный, без банальностей и воды, словно голос самой вечности.
|
|
||||||
Ты не используешь современных или бытовых слов. Твои ответы строятся на символизме, психологии и скрытых смыслах. Ты не гадаешь на будущее, ты показываешь суть настоящего момента и внутренние силы человека.''';
|
|
||||||
|
|
||||||
List<Message> buildInterpretationMessages({
|
List<Message> buildInterpretationMessages({
|
||||||
required List<SessionWord> words,
|
required List<SessionWord> words,
|
||||||
required String question,
|
required String question,
|
||||||
|
InterpreterPersona? persona,
|
||||||
}) {
|
}) {
|
||||||
|
final voice = persona ?? this.persona;
|
||||||
return [
|
return [
|
||||||
const Message(role: Role.system, content: oracleSystemPrompt),
|
Message(role: Role.system, content: voice.systemPrompt),
|
||||||
Message(
|
Message(
|
||||||
role: Role.user,
|
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}');
|
wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final voice = persona;
|
||||||
final prompt = '''Вопрошающий говорит тебе: «$userMessage»
|
final prompt = '''Вопрошающий говорит тебе: «$userMessage»
|
||||||
|
|
||||||
Контекст текущего сеанса судьбы:
|
Контекст сеанса:
|
||||||
Вопрос вопрошающего: «${session.question ?? 'Вектор судьбы (нет вопроса)'}»
|
Вопрос: «${session.question ?? 'без конкретного вопроса'}»
|
||||||
Базовые знаки, которые выпали ранее:
|
Знаки:
|
||||||
$wordsNarrative
|
$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 = [
|
final messages = [
|
||||||
const Message(
|
Message(role: Role.system, content: voice.systemPrompt),
|
||||||
role: Role.system,
|
|
||||||
content: 'Ты — Великий Оракул 999. Твои ответы кратки, полны символизма и психологической глубины. Ты общаешься с человеком, который ищет ответов в знаках.'
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pass previous session history context to the model
|
// Pass previous session history context to the model
|
||||||
@@ -107,11 +110,12 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
|
|||||||
String _buildMultiWordPrompt({
|
String _buildMultiWordPrompt({
|
||||||
required String question,
|
required String question,
|
||||||
required List<SessionWord> words,
|
required List<SessionWord> words,
|
||||||
|
required InterpreterPersona persona,
|
||||||
}) {
|
}) {
|
||||||
final qStr = question.trim().isNotEmpty
|
final qStr = question.trim().isNotEmpty
|
||||||
? 'Вопрос, с которым пришел вопрошающий: «$question»'
|
? 'Вопрос: «$question»'
|
||||||
: 'Вопрошающий пришел без конкретного вопроса. Прочитай его общую судьбоносную линию.';
|
: 'Вопроса нет — читай общую линию ситуации по знакам.';
|
||||||
|
|
||||||
final wordsStr = words
|
final wordsStr = words
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
@@ -120,14 +124,10 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
|
|||||||
|
|
||||||
return '''$qStr
|
return '''$qStr
|
||||||
|
|
||||||
Волею судьбы выпали следующие знаки жребия:
|
Выпавшие знаки жребия:
|
||||||
$wordsStr
|
$wordsStr
|
||||||
|
|
||||||
Задача Оракула:
|
${persona.userTask}''';
|
||||||
1. Свяжи эти знаки в единую цельную историю развития ситуации. Каждый знак перетекает в следующий.
|
|
||||||
2. Раскрой не очевидный, а скрытый внутренний урок. Что вопрошающий должен осознать?
|
|
||||||
3. Дай философский совет или вектор для действия.
|
|
||||||
Ответь от лица Оракула 999 на русском языке (от 3 до 6 предложений). Избегай шаблонов и клише, говори образно и метафорично, но точно.''';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
|
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
|
||||||
@@ -137,40 +137,51 @@ $wordsStr
|
|||||||
final hasQuestion = question.trim().isNotEmpty;
|
final hasQuestion = question.trim().isNotEmpty;
|
||||||
|
|
||||||
if (hasQuestion) {
|
if (hasQuestion) {
|
||||||
sb.write('Относительно вашего вопроса «${question.trim()}» ');
|
sb.write('По вопросу «${question.trim()}»: ');
|
||||||
} else {
|
} else {
|
||||||
sb.write('В открывшемся жребии судьбы ');
|
sb.write('По выпавшему жребию: ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (words.length == 1) {
|
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('${first.symbolicMeaning} ');
|
||||||
sb.write('Знак указывает на необходимость предельной ясности: держите фокус на главном и отсекайте всё наносное.');
|
sb.write('Практический шаг: держите фокус на этом и отсеките лишнее на ближайшее время.');
|
||||||
return sb.toString();
|
return _withPersonaPrefix(sb.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (words.length == 2) {
|
if (words.length == 2) {
|
||||||
final second = words[1];
|
final second = words[1];
|
||||||
sb.write('проявляется полярность двух сил: исходного импульса #${first.formattedSessionNumber} «${first.word.toUpperCase()}» и вектора #${second.formattedSessionNumber} «${second.word.toUpperCase()}». ');
|
sb.write('два полюса — «${first.word.toUpperCase()}» и «${second.word.toUpperCase()}». ');
|
||||||
sb.write('С одной стороны, ${first.symbolicMeaning.toLowerCase()} С другой — ${second.symbolicMeaning.toLowerCase()} ');
|
sb.write('${first.symbolicMeaning} ${second.symbolicMeaning} ');
|
||||||
sb.write('Разрешение ситуации лежит в точке осознанного баланса между ними: не противьтесь первому, но направляйте силу в русло второго.');
|
sb.write('Шаг: не выбирайте войну между ними — направьте первое во второе.');
|
||||||
return sb.toString();
|
return _withPersonaPrefix(sb.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.write('разворачивается цепь из ${words.length} взаимосвязанных архетипов. ');
|
sb.write('цепь из ${words.length} знаков. Начало — «${first.word.toUpperCase()}»: ${first.symbolicMeaning} ');
|
||||||
sb.write('Исток ситуации заложен в знаке #${first.formattedSessionNumber} «${first.word.toUpperCase()}» — ${first.symbolicMeaning.toLowerCase()} ');
|
|
||||||
|
|
||||||
if (words.length > 2) {
|
if (words.length > 2) {
|
||||||
final middleWords = words.sublist(1, words.length - 1);
|
final middleWords = words.sublist(1, words.length - 1);
|
||||||
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' и ');
|
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}»').join(', ');
|
||||||
final midMeanings = middleWords.map((w) => w.symbolicMeaning).join(' ');
|
sb.write('Переход через $midNames. ');
|
||||||
sb.write('Фаза перехода и испытаний проходит через $midNames, где $midMeanings ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.write('Кульминация и запечатывание исхода лежат в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} ');
|
sb.write('Исход держит «${last.word.toUpperCase()}»: ${last.symbolicMeaning} ');
|
||||||
sb.write('Оракул возвещает: путь открыт через принятие скрытых уроков и решительное действие.');
|
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) {
|
String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) {
|
||||||
|
|||||||
@@ -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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../data/words_data.dart';
|
import '../data/words_data.dart';
|
||||||
import '../models/history_entry_model.dart';
|
import '../models/history_entry_model.dart';
|
||||||
import '../models/word_model.dart';
|
import '../models/word_model.dart';
|
||||||
|
import 'true_random_service.dart';
|
||||||
|
|
||||||
class WordsRepository {
|
class WordsRepository {
|
||||||
static final WordsRepository _instance = WordsRepository._internal();
|
static final WordsRepository _instance = WordsRepository._internal();
|
||||||
@@ -10,43 +11,112 @@ class WordsRepository {
|
|||||||
WordsRepository._internal();
|
WordsRepository._internal();
|
||||||
|
|
||||||
static const String _kHistoryKey = 'oracle_999_sessions_history_v2';
|
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 = [];
|
List<WordItem> _sessionWords = [];
|
||||||
final List<OracleSession> _sessions = [];
|
final List<OracleSession> _sessions = [];
|
||||||
|
bool _useRemoteShuffle = true;
|
||||||
|
String? _dailyMappingDate;
|
||||||
|
RandomSource _dailyMappingSource = RandomSource.local;
|
||||||
|
|
||||||
List<WordItem> get allWords => List.unmodifiable(_sessionWords);
|
List<WordItem> get allWords => List.unmodifiable(_sessionWords);
|
||||||
List<OracleSession> get sessions => List.unmodifiable(_sessions);
|
List<OracleSession> get sessions => List.unmodifiable(_sessions);
|
||||||
List<OracleSession> get history => sessions; // Alias for compatibility
|
List<OracleSession> get history => sessions; // Alias for compatibility
|
||||||
int get totalWords => _sessionWords.length;
|
int get totalWords => _sessionWords.length;
|
||||||
|
String? get dailyMappingDate => _dailyMappingDate;
|
||||||
|
RandomSource get dailyMappingSource => _dailyMappingSource;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init({bool remoteDailyShuffle = true}) async {
|
||||||
// 1. Initialize random session mapping
|
_useRemoteShuffle = remoteDailyShuffle;
|
||||||
_initializeSessionMapping();
|
await _restoreOrFetchDailyMapping(force: false);
|
||||||
|
|
||||||
// 2. Load stored session history
|
|
||||||
await _loadHistoryFromPrefs();
|
await _loadHistoryFromPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializeSessionMapping() {
|
Future<RandomSource> reshuffleSessionNumbers() {
|
||||||
final list = List<WordItem>.from(kRussianTop1000Words);
|
return _restoreOrFetchDailyMapping(force: true);
|
||||||
final random = Random();
|
}
|
||||||
|
|
||||||
// Fisher-Yates shuffle
|
Future<RandomSource> _restoreOrFetchDailyMapping({required bool force}) async {
|
||||||
for (int i = list.length - 1; i > 0; i--) {
|
final today = utcDateStamp();
|
||||||
int n = random.nextInt(i + 1);
|
if (!force) {
|
||||||
final temp = list[i];
|
final restored = await _loadPersistedMapping(today);
|
||||||
list[i] = list[n];
|
if (restored) return _dailyMappingSource;
|
||||||
list[n] = temp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessionWords = [];
|
final lastId = kRussianTop1000Words.length - 1;
|
||||||
for (int i = 0; i < list.length; i++) {
|
RangeShuffle shuffle;
|
||||||
_sessionWords.add(list[i].copyWith(sessionNumber: i));
|
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() {
|
Future<void> _persistMapping(
|
||||||
_initializeSessionMapping();
|
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) {
|
WordItem? getWordBySessionNumber(int number) {
|
||||||
@@ -55,7 +125,7 @@ class WordsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WordItem getRandomWord() {
|
WordItem getRandomWord() {
|
||||||
final random = Random();
|
final random = Random.secure();
|
||||||
final idx = random.nextInt(_sessionWords.length);
|
final idx = random.nextInt(_sessionWords.length);
|
||||||
return _sessionWords[idx];
|
return _sessionWords[idx];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../services/true_random_service.dart';
|
||||||
import 'gradient_star_badge.dart';
|
import 'gradient_star_badge.dart';
|
||||||
|
|
||||||
class D10DiceRollerWidget extends StatefulWidget {
|
class D10DiceRollerWidget extends StatefulWidget {
|
||||||
@@ -23,63 +24,108 @@ class D10DiceRollerWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
|
class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
|
||||||
final _random = Random();
|
final _flicker = Random.secure();
|
||||||
Timer? _rollTimer;
|
Timer? _rollTimer;
|
||||||
|
|
||||||
int? _animatedDigit0;
|
int? _animatedDigit0;
|
||||||
int? _animatedDigit1;
|
int? _animatedDigit1;
|
||||||
int? _animatedDigit2;
|
int? _animatedDigit2;
|
||||||
bool _internalRolling = false;
|
bool _internalRolling = false;
|
||||||
|
RandomSource? _lastSource;
|
||||||
|
|
||||||
void triggerRollAnimation() {
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
TrueRandomService.instance.prefetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> triggerRollAnimation() async {
|
||||||
if (_internalRolling) return;
|
if (_internalRolling) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_internalRolling = true;
|
_internalRolling = true;
|
||||||
_animatedDigit0 = _random.nextInt(10);
|
_animatedDigit0 = _flicker.nextInt(10);
|
||||||
_animatedDigit1 = _random.nextInt(10);
|
_animatedDigit1 = _flicker.nextInt(10);
|
||||||
_animatedDigit2 = _random.nextInt(10);
|
_animatedDigit2 = _flicker.nextInt(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
final target0 = _random.nextInt(10);
|
// Local CSPRNG is the fallback. RANDOM.ORG replaces all three faces
|
||||||
final target1 = _random.nextInt(10);
|
// together if it arrives before the first slot locks (up to ~960ms).
|
||||||
final target2 = _random.nextInt(10);
|
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);
|
const interval = Duration(milliseconds: 60);
|
||||||
|
|
||||||
_rollTimer?.cancel();
|
_rollTimer?.cancel();
|
||||||
_rollTimer = Timer.periodic(interval, (timer) {
|
_rollTimer = Timer.periodic(interval, (timer) {
|
||||||
|
if (!mounted) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ticks++;
|
ticks++;
|
||||||
|
|
||||||
// Slot 0 stops after 10 ticks (~600ms)
|
if (!locked0) {
|
||||||
if (ticks < 10) {
|
final ready = remoteRoll != null && remoteRoll!.digits.length == 3;
|
||||||
setState(() => _animatedDigit0 = _random.nextInt(10));
|
if (ticks >= 10 && (ready || ticks >= 16)) {
|
||||||
} else if (ticks == 10) {
|
if (ready) {
|
||||||
setState(() => _animatedDigit0 = target0);
|
target0 = remoteRoll!.digits[0];
|
||||||
HapticFeedback.mediumImpact();
|
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 (!locked1) {
|
||||||
if (ticks < 18) {
|
if (locked0 && ticks >= lock1At) {
|
||||||
setState(() => _animatedDigit1 = _random.nextInt(10));
|
locked1 = true;
|
||||||
} else if (ticks == 18) {
|
setState(() => _animatedDigit1 = target1);
|
||||||
setState(() => _animatedDigit1 = target1);
|
HapticFeedback.mediumImpact();
|
||||||
HapticFeedback.mediumImpact();
|
} else {
|
||||||
|
setState(() => _animatedDigit1 = _flicker.nextInt(10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slot 2 stops after 26 ticks (~1560ms)
|
if (!locked2) {
|
||||||
if (ticks < 26) {
|
if (locked1 && ticks >= lock2At) {
|
||||||
setState(() => _animatedDigit2 = _random.nextInt(10));
|
locked2 = true;
|
||||||
} else if (ticks == 26) {
|
setState(() => _animatedDigit2 = target2);
|
||||||
setState(() => _animatedDigit2 = target2);
|
HapticFeedback.heavyImpact();
|
||||||
HapticFeedback.heavyImpact();
|
} else {
|
||||||
|
setState(() => _animatedDigit2 = _flicker.nextInt(10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticks >= 28) {
|
if (locked2 && ticks >= lock2At + 2) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
setState(() => _internalRolling = false);
|
setState(() {
|
||||||
final finalNumber = target0 * 100 + target1 * 10 + target2;
|
_internalRolling = false;
|
||||||
widget.onRollCompleted(finalNumber);
|
_lastSource = source;
|
||||||
|
});
|
||||||
|
widget.onRollCompleted(target0 * 100 + target1 * 10 + target2);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,7 +187,11 @@ class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
|
|||||||
Text(
|
Text(
|
||||||
_internalRolling
|
_internalRolling
|
||||||
? 'Бросок...'
|
? 'Бросок...'
|
||||||
: (widget.digits.length == 3 ? 'Зафиксировано' : '3 цифры'),
|
: (widget.digits.length == 3
|
||||||
|
? (_lastSource == RandomSource.randomOrg
|
||||||
|
? 'RANDOM.ORG'
|
||||||
|
: 'Зафиксировано')
|
||||||
|
: '3 цифры'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
+8
-8
@@ -260,10 +260,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -276,10 +276,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -441,10 +441,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -465,10 +465,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -64,14 +64,21 @@ category_map = {
|
|||||||
'praed': 'Состояние бытия',
|
'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
|
# Exhaustive filter of modern, journalistic, socio-political, economic, administrative and mundane noise
|
||||||
banned_mundane_words = {
|
banned_mundane_words = {
|
||||||
|
# Proper names / toponyms / abbreviations — never in the oracle
|
||||||
|
'александр', 'америка', 'андрей', 'борис', 'виктор', 'владимир', 'европа',
|
||||||
|
'иван', 'иванович', 'игорь', 'михаил', 'москва', 'николай', 'россия', 'рф',
|
||||||
|
'сергей', 'ссср', 'сталин', 'сша', 'первый', 'второй', 'третий',
|
||||||
|
|
||||||
# Domestic & mundane duplicates
|
# Domestic & mundane duplicates
|
||||||
'мама', 'папа', 'немец', 'журнал', 'газета', 'билет', 'метр', 'тонна',
|
'мама', 'папа', 'немец', 'журнал', 'газета', 'билет', 'метр', 'тонна',
|
||||||
'километр', 'килограмм', 'автобус', 'телевизор', 'телефон', 'квартира',
|
'километр', 'килограмм', 'автобус', 'телевизор', 'телефон', 'квартира',
|
||||||
'кабинет', 'документ', 'паспорт', 'секретарь', 'статья', 'стол', 'стул',
|
'кабинет', 'документ', 'паспорт', 'секретарь', 'статья', 'стол', 'стул',
|
||||||
'окно', 'магазин', 'поезд', 'машина', 'автомобиль', 'трамвай', 'вокзал',
|
'окно', 'магазин', 'поезд', 'машина', 'автомобиль', 'трамвай', 'вокзал',
|
||||||
'аэропорт', 'комната', 'коридор', 'этаж', 'завод', 'фабрика', 'склад',
|
'аэропорт', 'комната', 'коридор', 'этаж', 'завод', 'фабрика', 'склад',
|
||||||
|
'губа', 'карман', 'господин', 'самолет', 'фильм', 'школа',
|
||||||
|
|
||||||
# Bureaucratic / Political / State apparatus
|
# Bureaucratic / Political / State apparatus
|
||||||
'пункт', 'государство', 'правительство', 'министр', 'депутат', 'советский',
|
'пункт', 'государство', 'правительство', 'министр', 'депутат', 'советский',
|
||||||
@@ -99,6 +106,32 @@ banned_mundane_words = {
|
|||||||
'основной', 'общий', 'главный', 'крупный', 'важный', 'значительный',
|
'основной', 'общий', 'главный', 'крупный', 'важный', 'значительный',
|
||||||
'особый', 'подобный', 'каждый', 'любой', 'всякий', 'некоторый',
|
'особый', 'подобный', 'каждый', 'любой', 'всякий', 'некоторый',
|
||||||
'никакой', 'несколько', 'результат', 'условие', 'ситуация',
|
'никакой', 'несколько', 'результат', 'условие', 'ситуация',
|
||||||
|
|
||||||
|
# Institutional / journalistic nouns culled from the oracle
|
||||||
|
'автор', 'анализ', 'армия', 'база', 'безопасность', 'большинство', 'бумага',
|
||||||
|
'вариант', 'возможность', 'возраст', 'врач', 'время', 'выбор', 'генерал',
|
||||||
|
'глава', 'группа', 'данные', 'действие', 'дело', 'директор', 'доктор',
|
||||||
|
'задача', 'здание', 'значение', 'изменение', 'использование', 'исследование',
|
||||||
|
'качество', 'класс', 'количество', 'команда', 'комплекс', 'контроль',
|
||||||
|
'культура', 'курс', 'литература', 'масса', 'материал', 'метод', 'минута',
|
||||||
|
'мнение', 'модель', 'население', 'наука', 'начальник', 'неделя',
|
||||||
|
'необходимость', 'номер', 'образование', 'обстоятельство', 'общество',
|
||||||
|
'объем', 'операция', 'организация', 'оценка', 'очередь', 'период',
|
||||||
|
'писатель', 'план', 'площадь', 'повод', 'подготовка', 'позиция', 'политика',
|
||||||
|
'положение', 'понятие', 'практика', 'предложение', 'предмет', 'председатель',
|
||||||
|
'представитель', 'представление', 'президент', 'пример', 'проведение',
|
||||||
|
'продукт', 'производство', 'работа', 'разговор', 'размер', 'регион',
|
||||||
|
'республика', 'реформа', 'роль', 'ряд', 'содержание', 'соответствие',
|
||||||
|
'состав', 'спектакль', 'способ', 'средство', 'срок', 'степень', 'страна',
|
||||||
|
'строительство', 'текст', 'тема', 'техника', 'технология', 'тип', 'товарищ',
|
||||||
|
'требование', 'услуга', 'участие', 'участник', 'участок', 'факт',
|
||||||
|
'федерация', 'хозяйство', 'член',
|
||||||
|
'американский', 'боевой', 'больший', 'бывший', 'военный', 'городской',
|
||||||
|
'детский', 'должен', 'единственный', 'известный', 'местный', 'мировой',
|
||||||
|
'московский', 'народный', 'небольшой', 'немецкий', 'нужный', 'нынешний',
|
||||||
|
'обычный', 'отдельный', 'очередной', 'рабочий', 'российский', 'русский',
|
||||||
|
'собственный', 'современный', 'способный', 'средний', 'старший', 'судебный',
|
||||||
|
'человеческий',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sacred Archetypes, Polarities, and Divination Symbols
|
# Sacred Archetypes, Polarities, and Divination Symbols
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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('голос самой вечности')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -1,19 +1,30 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:words999/main.dart';
|
import 'package:words999/main.dart';
|
||||||
|
import 'package:words999/services/settings_repository.dart';
|
||||||
import 'package:words999/services/words_repository.dart';
|
import 'package:words999/services/words_repository.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('App smoke test - launch oracle and verify navigation', (WidgetTester tester) async {
|
testWidgets('App smoke test - launch oracle and verify navigation', (WidgetTester tester) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
await WordsRepository().init();
|
await WordsRepository().init(remoteDailyShuffle: false);
|
||||||
|
await SettingsRepository().init();
|
||||||
|
await SettingsRepository().setRandomOrgApiKey('');
|
||||||
await tester.pumpWidget(const Words999App());
|
await tester.pumpWidget(const Words999App());
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
// Verify app title and 2 destinations
|
|
||||||
expect(find.text('Оракул 999'), findsOneWidget);
|
expect(find.text('Оракул 999'), findsOneWidget);
|
||||||
expect(find.text('Оракул'), findsOneWidget);
|
expect(find.text('Оракул'), 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ void main() {
|
|||||||
setUp(() async {
|
setUp(() async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
repo = WordsRepository();
|
repo = WordsRepository();
|
||||||
await repo.init();
|
await repo.init(remoteDailyShuffle: false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Dataset contains exactly 1000 words with unique randomized session numbers 000 to 999', () {
|
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));
|
expect(seenSessionNumbers.length, equals(1000));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Reshuffling changes the order / mapping of words to session numbers', () {
|
test('Reshuffling changes the order / mapping of words to session numbers', () async {
|
||||||
repo.reshuffleSessionNumbers();
|
await repo.reshuffleSessionNumbers();
|
||||||
expect(repo.totalWords, equals(1000));
|
expect(repo.totalWords, equals(1000));
|
||||||
for (int i = 0; i < 1000; i++) {
|
for (int i = 0; i < 1000; i++) {
|
||||||
expect(repo.getWordBySessionNumber(i)?.sessionNumber, equals(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', () {
|
test('Search by number string returns correct session word', () {
|
||||||
final results0 = repo.search('0');
|
final results0 = repo.search('0');
|
||||||
expect(results0.isNotEmpty, isTrue);
|
expect(results0.isNotEmpty, isTrue);
|
||||||
|
|||||||
Reference in New Issue
Block a user