 | # L1JTW380 專案優化規劃文件
## 文件資訊
- **專案名稱**: L1JTW Lineage 1 Java Taiwan Server
- **文件版本**: v1.0
- **建立日期**: 2026-01-19
- **適用版本**: L1J 伺服器
---
## 目錄
1. [專案現況分析](#1-專案現況分析)
2. [模組化優化](#2-模組化優化)
3. [配置外部化](#3-配置外部化)
4. [資料庫讀取優化](#4-資料庫讀取優化)
5. [執行優先順序](#5-執行優先順序)
6. [風險評估](#6-風險評估)
---
## 1. 專案現況分析
### 1.1 專案結構概覽
```
L1JTW380/
├── src/ # 原始碼目錄
│ ├── l1j/server/ # 核心伺服器
│ │ ├── server/ # 遊戲邏輯
│ │ │ ├── clientpackets/ # 客戶端封包處理
│ │ │ ├── serverpackets/ # 伺服器封包處理
│ │ │ ├── datatables/ # 資料表存取 (50+ 檔案)
│ │ │ ├── storage/ # 資料儲存層
│ │ │ ├── model/ # 遊戲模型
│ │ │ ├── command/ # 指令系統
│ │ │ └── taskmanager/ # 任務管理
│ │ └── Config.java # 配置載入 (691行)
│ └── esdion/ # 自訂功能
│ ├── onlineReward/ # 線上獎勵系統
│ ├── autohunt/ # 自動掛機
│ ├── SweepStreet/ # 掃街活動
│ └── GMCommandUI/ # GM指令介面
├── config/ # 配置目錄
│ ├── server.properties # 伺服器配置
│ ├── hikari-production.properties # 資料庫連接池
│ ├── onlinereward.properties # 線上獎勵配置
│ ├── altsettings.properties # 進階設定
│ └── ... (共9個配置檔)
├── db/ # 資料庫
│ ├── 380.sql # 資料庫結構
│ └── 380結構.sql # 結構說明
├── lib/ # 函式庫
├── data/ # 遊戲資料
└── logs/ # 日誌目錄
```
### 1.2 程式碼統計
| 指標 | 數值 |
|------|------|
| Java 檔案總數 | 705 |
| 使用資料庫的檔案 | 85 |
| 配置檔案 | 9 |
| Config.java 行數 | 691 |
| 主要類別耦合度 | 高 |
| 配置分散程度 | 嚴重 |
### 1.3 現有問題清單
#### 模組化問題
- [ ] `Config.java` 過度膨脹 (691 行)
- [ ] 配置欄位散落各處 (static fields ~200+)
- [ ] 自訂功能 (`esdion/`) 缺乏統一架構
- [ ] 缺少抽象介面層
- [ ] SQL 處理模式重複
#### 配置問題
- [ ] 敏感資訊明文存放
- [ ] 缺少環境變數支援
- [ ] 配置變更需要重啟伺服器
- [ ] 配置檔案命名混亂
- [ ] 無配置版本管理
#### 資料庫問題
- [ ] 每次查詢建立 Connection (無連線複用)
- [ ] 缺少查詢快取機制
- [ ] 無慢查詢監控
- [ ] 讀寫分離預留未實作
- [ ] 無批量操作優化
---
## 2. 模組化優化
### 2.1 目標
建立清晰的架構分層,降低耦合度,提高可維護性。
### 2.2 架構設計
```
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (clientpackets) │
├─────────────────────────────────────────────────────────────┤
│ Application Layer │
│ (serverpackets) │
├─────────────────────────────────────────────────────────────┤
│ Service Layer │
│ (esdion/*/controller) │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ (model, templates) │
├─────────────────────────────────────────────────────────────┤
│ Repository Layer │
│ (datatables, storage) │
├─────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Config, DatabaseFactory) │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 Config.java 重構
#### 現有問題
- 靜態欄位過多 (~200+)
- 配置載入邏輯集中
- 難以單元測試
#### 重構方案
**步驟 1: 建立配置介面**
```java
// src/l1j/server/config/GameConfig.java
public interface GameConfig {
String getGameServerHostname();
int getGameServerPort();
short getMaxOnlineUsers();
// ... 其他方法
}
```
**步驟 2: 分類配置類別**
```
src/l1j/server/config/
├── Config.java # 工廠類別 (重構後)
├── DatabaseConfig.java # 資料庫配置
├── ServerConfig.java # 伺服器配置
├── RateConfig.java # 經驗/掉落倍率
├── AltSettingsConfig.java # 進階設定
├── CharSettingsConfig.java # 角色屬性設定
├── FightConfig.java # 戰鬥設定
├── RecordConfig.java # 日誌記錄設定
└── impl/
├── PropertiesConfigLoader.java
└── EnvironmentConfigLoader.java
```
**步驟 3: 配置類別範例**
```java
// src/l1j/server/config/DatabaseConfig.java
public class DatabaseConfig {
private static DatabaseConfig instance;
private String driver;
private String url;
private String username;
private String password;
private int minIdle;
private int maxPoolSize;
private long connectionTimeout;
// Getters
public String getDriver() { return driver; }
public String getUrl() { return url; }
// ...
// Singleton
public static synchronized DatabaseConfig getInstance() {
if (instance == null) {
instance = new DatabaseConfig();
instance.load();
}
return instance;
}
private void load() {
// 從配置檔載入
}
}
```
### 2.4 建立 DAO 抽象層
#### 目標
- 統一資料存取模式
- 減少重複程式碼
- 便於測試
#### 實作方案
```
src/l1j/server/server/storage/
├── CharacterStorage.java # 介面
├── ItemStorage.java # 介面
└── impl/
├── mysql/
│ ├── MySqlCharacterStorage.java
│ ├── MySqlItemStorage.java
│ └── BaseDao.java # 通用 CRUD
└── memory/
└── InMemoryCharacterStorage.java # 測試用
```
**BaseDao.java 範例**
```java
// src/l1j/server/server/storage/impl/mysql/BaseDao.java
public abstract class BaseDao<T, ID> {
protected abstract String getTableName();
protected abstract T mapRow(ResultSet rs) throws SQLException;
protected abstract ID getId(T entity);
public Optional<T> findById(ID id) {
String sql = "SELECT * FROM " + getTableName() + " WHERE id = ?";
try (Connection conn = DatabaseFactory.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, id);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
}
}
public List<T> findAll() {
String sql = "SELECT * FROM " + getTableName();
// ... 實作
}
public void save(T entity) {
// 判斷 insert 或 update
}
public int batchInsert(List<T> entities) {
String sql = buildBatchInsertSql();
try (Connection conn = DatabaseFactory.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (T entity : entities) {
bindParameters(stmt, entity);
stmt.addBatch();
}
return Arrays.stream(stmt.executeBatch())
.sum();
}
}
}
```
### 2.5 自訂功能重構
#### 現有結構
```
esdion/
├── onlineReward/
│ ├── controller/
│ ├── service/
│ ├── model/
│ ├── utils/
│ ├── OnlineRewardConfig.java
│ └── OnlineRewardManager.java
├── autohunt/
│ └── (7 個檔案)
├── SweepStreet/
│ └── (2 個檔案)
└── GMCommandUI/
└── (4 個檔案)
```
#### 建議結構
```
esdion/
├── core/ # 核心框架
│ ├── BaseFeature.java # 功能基底類別
│ ├── FeatureManager.java # 功能管理器
│ └── FeatureConfig.java # 通用配置介面
├── onlineReward/ # 保持現有結構
│ ├── controller/
│ ├── service/
│ ├── model/
│ ├── config/
│ │ └── OnlineRewardConfig.java
│ └── OnlineRewardFeature.java # 實作 BaseFeature
├── autohunt/
│ ├── config/
│ ├── service/
│ └── AutoHuntFeature.java
├── SweepStreet/
│ ├── config/
│ └── SweepStreetFeature.java
└── GMCommandUI/
└── GMCommandUIFeature.java
```
---
## 3. 配置外部化
### 3.1 目標
- 敏感資訊安全管理
- 支援多環境部署
- 配置熱重載
- 統一配置管理
### 3.2 配置結構規劃
```
config/
├── application.properties # 主配置入口
├── application-dev.properties # 開發環境
├── application-prod.properties # 生產環境
├── database/
│ ├── hikari.properties # 連接池配置
│ └── hikari-read.properties # 讀取連接池
│ └── hikari-write.properties # 寫入連接池
├── game/
│ ├── server.properties
│ ├── rates.properties
│ ├── altsettings.properties
│ ├── charsettings.properties
│ ├── fights.properties
│ └── record.properties
├── feature/
│ ├── onlinereward.properties
│ ├── sweepstreet.properties
│ └── autohunt.properties
└── logging/
├── log.properties
└── log4j2.xml
```
### 3.3 ConfigManager 實作
```java
// src/l1j/server/server/utils/ConfigManager.java
public class ConfigManager {
private static ConfigManager instance;
private final Properties properties;
private final Map<String, Object> cache;
private final ScheduledExecutorService scheduler;
private ConfigManager() {
this.properties = new Properties();
this.cache = new ConcurrentHashMap<>();
this.scheduler = Executors.newSingleThreadScheduledExecutor();
loadConfiguration();
}
public static synchronized ConfigManager getInstance() {
if (instance == null) {
instance = new ConfigManager();
}
return instance;
}
private void loadConfiguration() {
// 載入順序:環境變數 > 命令列 > 預設配置
loadFromEnvironment();
loadFromFile("config/application.properties");
loadFromFile("config/application-" + getEnvironment() + ".properties");
}
public <T> T get(String key, Class<T> type) {
return get(key, type, null);
}
public <T> T get(String key, Class<T> type, T defaultValue) {
String envKey = convertToEnvKey(key);
String envValue = System.getenv(envKey);
if (envValue != null) {
return convert(envValue, type);
}
return cache.computeIfAbsent(key, k -> {
String value = properties.getProperty(k);
return value != null ? convert(value, type) : defaultValue;
});
}
private void loadFromEnvironment() {
// 環境變數自動注入
System.getenv().forEach((key, value) -> {
if (key.startsWith("L1JTW_")) {
String configKey = key.substring(6).toLowerCase().replace('_', '.');
properties.setProperty(configKey, value);
}
});
}
public void reload() {
cache.clear();
loadConfiguration();
notifyListeners();
}
public void watch(String configFile) {
Path path = Paths.get(configFile).toAbsolutePath();
scheduler.scheduleWithFixedDelay(() -> {
if (hasChanged(path)) {
reload();
}
}, 5, 5, TimeUnit.SECONDS);
}
}
```
### 3.4 敏感資訊管理
#### 環境變數命名規範
```
L1JTW_DB_URL=jdbc:mysql://localhost/380
L1JTW_DB_USERNAME=root
L1JTW_DB_PASSWORD=your_secure_password
L1JTW_REDIS_HOST=localhost
L1JTW_REDIS_PASSWORD=your_redis_password
```
#### 密碼處理範例
```java
public class SecureConfig {
private static final String DB_PASSWORD_KEY = "l1jtw.db.password";
public static String getDatabasePassword() {
// 優先讀取環境變數
String password = System.getenv("L1JTW_DB_PASSWORD");
if (password != null) {
return password;
}
// 嘗試從加密檔案讀取
try {
return decrypt(readEncryptedFile("config/.dbpass"));
} catch (Exception e) {
throw new RuntimeException("無法載入資料庫密碼", e);
}
}
}
```
### 3.5 配置熱重載
```java
// 配置監聽器
public interface ConfigChangeListener {
void onConfigChanged(String key, Object oldValue, Object newValue);
}
// 使用範例
ConfigManager.getInstance().addListener("MAX_ONLINE_USERS", (key, old, newVal) -> {
ServerConfig.setMaxOnlineUsers((Short) newVal);
Announcements.getInstance().announceToAll("伺服器容量已更新: " + newVal + " 人");
});
```
---
## 4. 資料庫讀取優化
### 4.1 目標
- 降低資料庫連接壓力
- 減少重複查詢
- 提高查詢效率
- 建立監控機制
### 4.2 連接池優化
#### 現有配置 (hikari-production.properties)
```properties
# 已優化項目
minimumIdle=60
maximumPoolSize=500
connectionTimeout=10000
idleTimeout=300000
maxLifetime=900000
leakDetectionThreshold=30000
```
#### 建議新增配置
```properties
# 新增監控配置
metricsRegistry=CODAHALE
# 連接健康檢查
connectionHealthCheck=SELECT 1
# 慢查詢閾值 (毫秒)
slowQueryThresholdMs=1000
# 語句快取優化
dataSource.prepStmtCacheSize=1000
dataSource.prepStmtCacheSqlLimit=8192
```
### 4.3 查詢快取機制
#### 使用 Caffeine 快取
```java
// src/l1j/server/server/utils/CacheManager.java
public class CacheManager {
private static CacheManager instance;
private final Cache<String, Object> queryCache;
private final LoadingCache<String, Object> hotDataCache;
private CacheManager() {
this.queryCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
this.hotDataCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterAccess(30, TimeUnit.MINUTES)
.refreshAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build(key -> loadHotData(key));
}
public <T> T getQueryResult(String queryKey, Class<T> type,
Supplier<T> dataLoader) {
return queryCache.get(queryKey, k -> dataLoader.get());
}
public <T> T getHotData(String dataKey, Class<T> type,
Supplier<T> dataLoader) {
return hotDataCache.get(dataKey, k -> dataLoader.get());
}
public CacheStats getStats() {
return queryCache.stats();
}
}
```
#### 快取策略
| 資料類型 | 快取策略 | TTL | 說明 |
|----------|----------|-----|------|
| 物品模板 | LoadingCache | 30分鐘 | 頻繁讀取 |
| NPC 資料 | LoadingCache | 30分鐘 | 變動少 |
| 地圖資料 | LoadingCache | 1小時 | 幾乎不變 |
| 玩家資料 | 不快取 | - | 實時性要求高 |
| 排行榜 | QueryCache | 5分鐘 | 定期更新 |
| 商店物品 | LoadingCache | 10分鐘 | 活動影響 |
### 4.4 批量操作優化
```java
// src/l1j/server/server/storage/impl/mysql/BatchOperations.java
public class BatchOperations {
/**
* 批量儲存玩家資料
* 使用事務 + 批次處理
*/
public int batchSaveCharacters(List<L1PcInstance> characters) {
if (characters.isEmpty()) return 0;
String sql = """
INSERT INTO characters
(account_name, char_name, objid, HighLevel, Exp, MaxHp, CurHp,
MaxMp, CurMp, Str, Con, Dex, Cha, Intel, Wis, Status, Class,
Sex, Type, locX, locY, MapID, Food, Lawful, Title, ClanID,
ClanRank, OnlineStatus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
HighLevel=VALUES(HighLevel), Exp=VALUES(Exp),
locX=VALUES(locX), locY=VALUES(locY), MapID=VALUES(MapID)
""";
try (Connection conn = DatabaseFactory.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
for (L1PcInstance pc : characters) {
bindCharacterParams(stmt, pc);
stmt.addBatch();
}
int[] results = stmt.executeBatch();
conn.commit();
return Arrays.stream(results).sum();
} catch (SQLException e) {
conn.rollback();
throw e;
}
}
}
/**
* 批量更新玩家狀態
*/
public void batchUpdateOnlineStatus(Map<Integer, Integer> statusMap) {
String sql = "UPDATE characters SET OnlineStatus=? WHERE objid=?";
// 實作批次更新
}
}
```
### 4.5 讀寫分離實作
#### 目標架構
```
┌─────────────────┐
│ Application │
└────────┬────────┘
│
┌────────┴────────┐
│ │
┌────▼────┐ ┌────▼────┐
│ Read │ │ Write │
│ Pool │ │ Pool │
│ (70%) │ │ (30%) │
└────┬────┘ └────┬────┘
│ │
└────────┬────────┘
│
┌────────▼────────┐
│ MySQL Master │
│ (Read/Write) │
└─────────────────┘
```
#### 實作程式碼
```java
// src/l1j/server/L1DatabaseFactory.java
public class L1DatabaseFactory {
private HikariDataSource writeSource; // 主庫 (寫入)
private HikariDataSource readSource; // 從庫 (讀取)
private boolean readWriteSeparationEnabled;
public Connection getWriteConnection() {
if (!readWriteSeparationEnabled) {
return getConnection();
}
try {
Connection conn = writeSource.getConnection();
return LeakCheckedConnection.create(conn);
} catch (SQLException e) {
_log.log(Level.WARNING, "無法取得寫入連線,回退主連線");
return getConnection();
}
}
public Connection getReadConnection() {
if (!readWriteSeparationEnabled) {
return getConnection();
}
try {
Connection conn = readSource.getConnection();
return LeakCheckedConnection.create(conn);
} catch (SQLException e) {
_log.log(Level.WARNING, "無法取得讀取連線,回退主連線");
return getConnection();
}
}
/**
* 智慧路由:自動選擇連線
*/
public Connection getSmartConnection(String sql) {
if (!readWriteSeparationEnabled) {
return getConnection();
}
return isReadQuery(sql) ? getReadConnection() : getWriteConnection();
}
private boolean isReadQuery(String sql) {
String upper = sql.toUpperCase().trim();
return upper.startsWith("SELECT") ||
upper.startsWith("SHOW") ||
upper.startsWith("DESCRIBE");
}
}
```
### 4.6 監控與告警
```java
// src/l1j/server/server/monitoring/DatabaseMonitor.java
public class DatabaseMonitor {
private final ScheduledExecutorService scheduler;
public void startMonitoring() {
scheduler.scheduleAtFixedRate(this::logPoolStats, 30, 30, TimeUnit.SECONDS);
scheduler.scheduleAtFixedRate(this::checkSlowQueries, 1, 1, TimeUnit.MINUTES);
}
private void logPoolStats() {
HikariPoolMXBean pool = getPoolMXBean();
if (pool != null) {
_log.info(String.format(
"[DB] Pool: active=%d, idle=%d, waiting=%d, total=%d",
pool.getActiveConnections(),
pool.getIdleConnections(),
pool.getThreadsAwaitingConnection(),
pool.getTotalConnections()
));
}
}
private void checkSlowQueries() {
// 檢查並記錄慢查詢
List<SlowQuery> slowQueries = queryLogger.getSlowQueries(1000);
if (!slowQueries.isEmpty()) {
_log.warning("[DB] 發現 " + slowQueries.size() + " 筆慢查詢");
slowQueries.forEach(q -> _log.warning("[DB] " + q));
}
}
}
```
---
## 5. 執行優先順序
### 5.1 階段規劃
#### Phase 1: 基礎重構 (Week 1-2)
| 任務 | 預估時間 | 風險 | 產出 |
|------|----------|------|------|
| 建立 config 目錄結構 | 2小時 | 低 | config/ 重組 |
| Config.java 分離 | 4小時 | 中 | 5 個配置類別 |
| ConfigManager 基礎版 | 4小時 | 中 | 配置載入器 |
| 建立 DAO 基礎類別 | 6小時 | 低 | BaseDao.java |
#### Phase 2: 配置外部化 (Week 3-4)
| 任務 | 預估時間 | 風險 | 產出 |
|------|----------|------|------|
| 環境變數支援 | 2小時 | 低 | L1JTW_* 支援 |
| 敏感資訊處理 | 4小時 | 高 | 加密配置 |
| 配置熱重載 | 4小時 | 中 | 動態更新 |
| 配置監聽器 | 2小時 | 低 | ConfigListener |
#### Phase 3: 資料庫優化 (Week 5-6)
| 任務 | 預估時間 | 風險 | 產出 |
|------|----------|------|------|
| CacheManager | 4小時 | 中 | 快取層 |
| 批量操作 | 6小時 | 中 | BatchOperations |
| 讀寫分離 | 8小時 | 高 | 雙連接池 |
| 監控儀表板 | 4小時 | 低 | JMX 監控 |
#### Phase 4: 測試與部署 (Week 7-8)
| 任務 | 預估時間 | 風險 | 產出 |
|------|----------|------|------|
| 單元測試 | 8小時 | 低 | 測試覆蓋率 |
| 整合測試 | 8小時 | 中 | 測試案例 |
| 文件更新 | 4小時 | 低 | 更新 README |
| 部署腳本 | 4小時 | 低 | startup.sh |
### 5.2 資源評估
| 項目 | 人力 | 時間 | 總工時 |
|------|------|------|--------|
| Phase 1 | 1 人 | 2 週 | 80 小時 |
| Phase 2 | 1 人 | 2 週 | 80 小時 |
| Phase 3 | 1 人 | 2 週 | 120 小時 |
| Phase 4 | 1 人 | 2 週 | 120 小時 |
| **合計** | 1 人 | 8 週 | **400 小時** |
---
## 6. 風險評估
### 6.1 風險清單
| 風險 ID | 風險描述 | 發生機率 | 影響程度 | 緩解措施 |
|---------|----------|----------|----------|----------|
| R001 | 配置錯誤導致服務無法啟動 | 中 | 高 | 保留後備配置 |
| R002 | 資料庫連接池配置不當 | 中 | 高 | 漸進式調整 |
| R003 | 快取導致資料不一致 | 低 | 中 | TTL 設定 |
| R004 | 讀寫分離造成延遲 | 中 | 中 | 監控與回滾 |
| R005 | 熱重載出錯 | 低 | 中 | 配置驗證 |
| R006 | 測試覆蓋不足 | 中 | 中 | 增加測試案例 |
### 6.2 回滾計畫
每個階段部署時需準備:
1. **配置變更回滾**
```
git checkout config/
```
2. **程式碼回滾**
```
git checkout src/
```
3. **資料庫回滾**
```sql
-- 保留原始表格結構
ALTER TABLE characters RENAME TO characters_backup;
```
### 6.3 驗收標準
#### 功能驗收
- [ ] 伺服器正常啟動
- [ ] 玩家可正常登入
- [ ] 遊戲功能正常運作
- [ ] 配置變更生效
#### 效能驗證
- [ ] 資料庫查詢時間 < 10ms
- [ ] 連接池使用率 < 80%
- [ ] 無慢查詢 ( > 1s )
---
## 附錄
### A. 配置檔案範例
#### application.properties
```properties
# L1JTW380 主配置
l1jtw.environment=production
l1jtw.server.hostname=*
l1jtw.server.port=2000
l1jtw.server.max-users=500
# 資料庫配置
l1jtw.db.driver=com.mysql.cj.jdbc.Driver
l1jtw.db.url=jdbc:mysql://localhost/380
l1jtw.db.min-idle=60
l1jtw.db.max-pool-size=500
l1jtw.db.timeout=10000
# 快取配置
l1jtw.cache.enabled=true
l1jtw.cache.hot-data-ttl=1800
l1jtw.cache.query-ttl=300
```
### B. 環境變數清單
```bash
# 資料庫
export L1JTW_DB_URL="jdbc:mysql://localhost/380"
export L1JTW_DB_USERNAME="root"
export L1JTW_DB_PASSWORD="your_password"
# Redis (未來擴展)
export L1JTW_REDIS_HOST="localhost"
export L1JTW_REDIS_PASSWORD=""
# 監控
export L1JTW_METRICS_ENABLED=true
export L1JTW_LOG_LEVEL="INFO"
```
### C. 監控指標
```java
// JMX MBean 註冊
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("l1jtw.server:type=DatabasePool");
DatabasePoolMXBean bean = new DatabasePoolMXBean(pool);
mbs.registerMBean(bean, name);
```
---
## 文件變更紀錄
| 版本 | 日期 | 變更內容 | 作者 |
|------|------|----------|------|
| v1.0 | 2026-01-19 | 初版建立 | OpenCode AI |
| |