@game_ryo/lsji 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.yml +46 -0
- package/AGENTS.md +143 -0
- package/LICENSE +185 -0
- package/PROPOSAL.md +18 -0
- package/README.md +102 -0
- package/bin/lsji.js +8 -0
- package/docs/README.md +43 -0
- package/docs/blog/2019-05-28-first-blog-post.mdx +12 -0
- package/docs/blog/2019-05-29-long-blog-post.mdx +44 -0
- package/docs/blog/2021-08-01-mdx-blog-post.mdx +24 -0
- package/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg +0 -0
- package/docs/blog/2021-08-26-welcome/index.mdx +29 -0
- package/docs/blog/authors.yml +25 -0
- package/docs/blog/tags.yml +19 -0
- package/docs/docs/api/agent.md +151 -0
- package/docs/docs/api/env.md +133 -0
- package/docs/docs/api/environments.md +102 -0
- package/docs/docs/api/qlearning.md +138 -0
- package/docs/docs/api/storage.md +168 -0
- package/docs/docs/architecture.md +155 -0
- package/docs/docs/cli.md +210 -0
- package/docs/docs/contributing.md +162 -0
- package/docs/docs/core-concepts.md +152 -0
- package/docs/docs/examples/advanced-training.md +244 -0
- package/docs/docs/examples/custom-environment.md +198 -0
- package/docs/docs/examples/custom-storage.md +251 -0
- package/docs/docs/getting-started.md +91 -0
- package/docs/docusaurus.config.ts +149 -0
- package/docs/package-lock.json +19522 -0
- package/docs/package.json +49 -0
- package/docs/sidebars.ts +33 -0
- package/docs/src/components/HomepageFeatures/index.tsx +71 -0
- package/docs/src/components/HomepageFeatures/styles.module.css +11 -0
- package/docs/src/css/custom.css +79 -0
- package/docs/src/pages/index.module.css +23 -0
- package/docs/src/pages/index.tsx +44 -0
- package/docs/src/pages/markdown-page.mdx +7 -0
- package/docs/static/.nojekyll +0 -0
- package/docs/static/img/docusaurus-social-card.jpg +0 -0
- package/docs/static/img/docusaurus.png +0 -0
- package/docs/static/img/favicon.ico +0 -0
- package/docs/static/img/logo.png +0 -0
- package/docs/static/img/undraw_docusaurus_mountain.svg +171 -0
- package/docs/static/img/undraw_docusaurus_react.svg +170 -0
- package/docs/static/img/undraw_docusaurus_tree.svg +40 -0
- package/docs/tsconfig.json +12 -0
- package/legacy/worker.js +166 -0
- package/legacy/wrangler.toml +11 -0
- package/package.json +26 -0
- package/src/cli.js +232 -0
- package/src/core/agent.js +239 -0
- package/src/core/env.js +86 -0
- package/src/core/qlearning.js +197 -0
- package/src/envs/rps.js +168 -0
- package/src/index.js +22 -0
- package/src/storage/better-sqlite.js +133 -0
- package/src/storage/index.js +146 -0
- package/src/storage/memory.js +98 -0
- package/src/storage/sqlite.js +123 -0
- package/test/core/qlearning.test.js +150 -0
- package/test/storage/memory.test.js +81 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Q-Learning Engine (Temporal Difference Learning)
|
|
3
|
+
*
|
|
4
|
+
* Migrated from the original Cloudflare Workers implementation.
|
|
5
|
+
* Implements tabular Q-learning with epsilon-greedy exploration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { StateEncoder } from './env.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Q-Learning configuration
|
|
12
|
+
* @typedef {Object} QLearningConfig
|
|
13
|
+
* @property {number} [alpha=0.1] - Learning rate (0.0 to 1.0)
|
|
14
|
+
* @property {number} [gamma=0.9] - Discount factor (0.0 to 1.0)
|
|
15
|
+
* @property {number} [epsilon=0.1] - Exploration rate (0.0 to 1.0)
|
|
16
|
+
* @property {Storage} storage - Storage backend for Q-table persistence
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Q-Learning Engine for Tabular Reinforcement Learning
|
|
21
|
+
*/
|
|
22
|
+
export class QLearning {
|
|
23
|
+
/**
|
|
24
|
+
* @param {QLearningConfig} config
|
|
25
|
+
*/
|
|
26
|
+
constructor({ alpha = 0.1, gamma = 0.9, epsilon = 0.1, storage } = {}) {
|
|
27
|
+
if (!storage) {
|
|
28
|
+
throw new Error('Storage is required for QLearning');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.alpha = Math.max(0, Math.min(1, alpha));
|
|
32
|
+
this.gamma = Math.max(0, Math.min(1, gamma));
|
|
33
|
+
this.epsilon = Math.max(0, Math.min(1, epsilon));
|
|
34
|
+
this.storage = storage;
|
|
35
|
+
|
|
36
|
+
// In-memory Q-table cache for performance
|
|
37
|
+
this.qTable = new Map();
|
|
38
|
+
this.initialized = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initialize Q-table from storage
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async initialize() {
|
|
46
|
+
if (this.initialized) return;
|
|
47
|
+
|
|
48
|
+
const records = await this.storage.getQTable();
|
|
49
|
+
for (const record of records) {
|
|
50
|
+
const key = `${record.state}:${record.action}`;
|
|
51
|
+
this.qTable.set(key, record.q_value);
|
|
52
|
+
}
|
|
53
|
+
this.initialized = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get Q-value for state-action pair
|
|
58
|
+
* @param {string} state - State key
|
|
59
|
+
* @param {number} action - Action
|
|
60
|
+
* @returns {number} Q-value (0 if unseen)
|
|
61
|
+
*/
|
|
62
|
+
getQValue(state, action) {
|
|
63
|
+
return this.qTable.get(`${state}:${action}`) ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set Q-value for state-action pair
|
|
68
|
+
* @param {string} state - State key
|
|
69
|
+
* @param {number} action - Action
|
|
70
|
+
* @param {number} value - Q-value
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
async setQValue(state, action, value) {
|
|
74
|
+
const key = `${state}:${action}`;
|
|
75
|
+
this.qTable.set(key, value);
|
|
76
|
+
await this.storage.updateQ(state, action, value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Select action using epsilon-greedy policy
|
|
81
|
+
* @param {string} state - Current state
|
|
82
|
+
* @param {number} actionSize - Number of possible actions
|
|
83
|
+
* @returns {Promise<number>} Selected action
|
|
84
|
+
*/
|
|
85
|
+
async act(state, actionSize) {
|
|
86
|
+
await this.initialize();
|
|
87
|
+
|
|
88
|
+
// Exploration: random action
|
|
89
|
+
if (Math.random() < this.epsilon) {
|
|
90
|
+
return Math.floor(Math.random() * actionSize);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Exploitation: best known action
|
|
94
|
+
let bestAction = 0;
|
|
95
|
+
let bestValue = -Infinity;
|
|
96
|
+
|
|
97
|
+
for (let action = 0; action < actionSize; action++) {
|
|
98
|
+
const value = this.getQValue(state, action);
|
|
99
|
+
if (value > bestValue || (value === bestValue && Math.random() < 0.5)) {
|
|
100
|
+
bestValue = value;
|
|
101
|
+
bestAction = action;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return bestAction;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Update Q-value using TD learning rule
|
|
110
|
+
* Q(s,a) <- Q(s,a) + alpha * (reward + gamma * max_a' Q(s',a') - Q(s,a))
|
|
111
|
+
*
|
|
112
|
+
* @param {string} state - Current state
|
|
113
|
+
* @param {number} action - Action taken
|
|
114
|
+
* @param {number} reward - Reward received
|
|
115
|
+
* @param {string} nextState - Next state
|
|
116
|
+
* @param {number} nextActionSize - Number of actions in next state
|
|
117
|
+
* @returns {Promise<number>} Updated Q-value
|
|
118
|
+
*/
|
|
119
|
+
async learn(state, action, reward, nextState, nextActionSize) {
|
|
120
|
+
await this.initialize();
|
|
121
|
+
|
|
122
|
+
const currentQ = this.getQValue(state, action);
|
|
123
|
+
|
|
124
|
+
// Find max Q-value for next state
|
|
125
|
+
let maxNextQ = 0;
|
|
126
|
+
for (let a = 0; a < nextActionSize; a++) {
|
|
127
|
+
const q = this.getQValue(nextState, a);
|
|
128
|
+
if (q > maxNextQ) maxNextQ = q;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// TD update: Q(s,a) = Q(s,a) + alpha * (reward + gamma * max Q(s',a') - Q(s,a))
|
|
132
|
+
const target = reward + this.gamma * maxNextQ;
|
|
133
|
+
const newQ = currentQ + this.alpha * (target - currentQ);
|
|
134
|
+
|
|
135
|
+
await this.setQValue(state, action, newQ);
|
|
136
|
+
return newQ;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Simple Q-value update (original worker.js style)
|
|
141
|
+
* Q(s,a) <- Q(s,a) + alpha * (reward - Q(s,a))
|
|
142
|
+
* Used for terminal states or simplified updates
|
|
143
|
+
*
|
|
144
|
+
* @param {string} state - State
|
|
145
|
+
* @param {number} action - Action
|
|
146
|
+
* @param {number} reward - Reward
|
|
147
|
+
* @returns {Promise<number>} Updated Q-value
|
|
148
|
+
*/
|
|
149
|
+
async learnSimple(state, action, reward) {
|
|
150
|
+
await this.initialize();
|
|
151
|
+
|
|
152
|
+
const currentQ = this.getQValue(state, action);
|
|
153
|
+
// Original formula: oldQ + 0.1 * (reward - oldQ)
|
|
154
|
+
const newQ = currentQ + this.alpha * (reward - currentQ);
|
|
155
|
+
|
|
156
|
+
await this.setQValue(state, action, newQ);
|
|
157
|
+
return newQ;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reset Q-table (clear all learned values)
|
|
162
|
+
* @returns {Promise<void>}
|
|
163
|
+
*/
|
|
164
|
+
async reset() {
|
|
165
|
+
this.qTable.clear();
|
|
166
|
+
// Note: storage reset would require a new method
|
|
167
|
+
this.initialized = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get all Q-values for a state
|
|
172
|
+
* @param {string} state - State key
|
|
173
|
+
* @param {number} actionSize - Number of actions
|
|
174
|
+
* @returns {Object<string, number>} Action -> Q-value mapping
|
|
175
|
+
*/
|
|
176
|
+
getStateValues(state, actionSize) {
|
|
177
|
+
const values = {};
|
|
178
|
+
for (let action = 0; action < actionSize; action++) {
|
|
179
|
+
values[action] = this.getQValue(state, action);
|
|
180
|
+
}
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get entire Q-table (for inspection/debugging)
|
|
186
|
+
* @returns {Promise<Array<{state: string, action: number, q_value: number}>>}
|
|
187
|
+
*/
|
|
188
|
+
async getFullQTable() {
|
|
189
|
+
await this.initialize();
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const [key, value] of this.qTable) {
|
|
192
|
+
const [state, action] = key.split(':');
|
|
193
|
+
results.push({ state, action: parseInt(action, 10), q_value: value });
|
|
194
|
+
}
|
|
195
|
+
return results.sort((a, b) => a.state.localeCompare(b.state) || a.action - b.action);
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/envs/rps.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rock-Paper-Scissors Environment
|
|
3
|
+
*
|
|
4
|
+
* Implements the Env interface for the classic RPS game.
|
|
5
|
+
* Migrated from the original Cloudflare Workers implementation.
|
|
6
|
+
*
|
|
7
|
+
* State: The opponent's last action (0=Rock, 1=Scissors, 2=Paper)
|
|
8
|
+
* Actions: 0=Rock, 1=Scissors, 2=Paper
|
|
9
|
+
* Rewards: Win=1, Lose=-1, Draw=0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Env } from '../core/env.js';
|
|
13
|
+
|
|
14
|
+
const HAND_NAMES = ['Rock', 'Scissors', 'Paper'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opponent strategy type
|
|
18
|
+
* @typedef {'random'|'always_rock'|'counter'|'sequential'} OpponentStrategy
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* RockPaperScissorsEnv - Classic RPS environment
|
|
23
|
+
*/
|
|
24
|
+
export class RockPaperScissorsEnv extends Env {
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} options
|
|
27
|
+
* @param {OpponentStrategy} [options.opponent='random'] - Opponent strategy
|
|
28
|
+
*/
|
|
29
|
+
constructor({ opponent = 'random' } = {}) {
|
|
30
|
+
super();
|
|
31
|
+
this.opponent = opponent;
|
|
32
|
+
this.lastOpponentAction = 0;
|
|
33
|
+
this.episodeCount = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get current state (opponent's last action)
|
|
38
|
+
* @returns {string} State key
|
|
39
|
+
*/
|
|
40
|
+
getState() {
|
|
41
|
+
return String(this.lastOpponentAction);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Execute action against opponent
|
|
46
|
+
* @param {number} action - Agent's action (0, 1, 2)
|
|
47
|
+
* @returns {Promise<StepResult>} Step result
|
|
48
|
+
*/
|
|
49
|
+
async step(action) {
|
|
50
|
+
// Determine opponent action based on strategy
|
|
51
|
+
let opponentAction;
|
|
52
|
+
switch (this.opponent) {
|
|
53
|
+
case 'always_rock':
|
|
54
|
+
opponentAction = 0;
|
|
55
|
+
break;
|
|
56
|
+
case 'counter':
|
|
57
|
+
// Counter agent's previous action
|
|
58
|
+
opponentAction = this.episodeCount === 0 ? 0 : (action + 2) % 3;
|
|
59
|
+
break;
|
|
60
|
+
case 'sequential':
|
|
61
|
+
opponentAction = this.episodeCount % 3;
|
|
62
|
+
break;
|
|
63
|
+
case 'random':
|
|
64
|
+
default:
|
|
65
|
+
opponentAction = Math.floor(Math.random() * 3);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Calculate reward from agent's perspective
|
|
69
|
+
// judge = (agent - opponent + 3) % 3
|
|
70
|
+
// 2 = agent wins, 1 = agent loses, 0 = draw
|
|
71
|
+
const judge = (action - opponentAction + 3) % 3;
|
|
72
|
+
const reward = judge === 2 ? 1 : judge === 1 ? -1 : 0;
|
|
73
|
+
|
|
74
|
+
// Update state for next step
|
|
75
|
+
this.lastOpponentAction = opponentAction;
|
|
76
|
+
this.episodeCount++;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
state: String(opponentAction),
|
|
80
|
+
reward,
|
|
81
|
+
done: false,
|
|
82
|
+
info: { opponentAction, judge }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Number of possible actions (Rock, Paper, Scissors)
|
|
88
|
+
* @returns {number} 3
|
|
89
|
+
*/
|
|
90
|
+
actionSize() {
|
|
91
|
+
return 3;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reset environment to initial state
|
|
96
|
+
* @returns {Promise<string>} Initial state
|
|
97
|
+
*/
|
|
98
|
+
async reset() {
|
|
99
|
+
this.lastOpponentAction = 0;
|
|
100
|
+
this.episodeCount = 0;
|
|
101
|
+
return '0';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render current state
|
|
106
|
+
* @returns {string} Human-readable representation
|
|
107
|
+
*/
|
|
108
|
+
render() {
|
|
109
|
+
return `RPS Env | Last opponent: ${HAND_NAMES[this.lastOpponentAction]} | Episodes: ${this.episodeCount}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get human-readable hand name
|
|
114
|
+
* @param {number} hand - Hand index (0, 1, 2)
|
|
115
|
+
* @returns {string} Hand name
|
|
116
|
+
*/
|
|
117
|
+
static getHandName(hand) {
|
|
118
|
+
return HAND_NAMES[hand] || 'Unknown';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Calculate outcome between two hands
|
|
123
|
+
* @param {number} agentHand - Agent's hand
|
|
124
|
+
* @param {number} opponentHand - Opponent's hand
|
|
125
|
+
* @returns {{judge: number, reward: number, outcome: string}} Result
|
|
126
|
+
*/
|
|
127
|
+
static calculateOutcome(agentHand, opponentHand) {
|
|
128
|
+
const judge = (agentHand - opponentHand + 3) % 3;
|
|
129
|
+
const reward = judge === 2 ? 1 : judge === 1 ? -1 : 0;
|
|
130
|
+
const outcome = judge === 2 ? 'WIN' : judge === 1 ? 'LOSE' : 'DRAW';
|
|
131
|
+
return { judge, reward, outcome };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Training pattern definitions (matching original worker.js)
|
|
137
|
+
* @readonly
|
|
138
|
+
* @enum {number}
|
|
139
|
+
*/
|
|
140
|
+
export const TrainingPattern = {
|
|
141
|
+
RANDOM: 0, // Random actions
|
|
142
|
+
ALWAYS_ROCK: 1, // Always play Rock
|
|
143
|
+
COUNTER: 2, // Counter previous action
|
|
144
|
+
SEQUENTIAL: 3 // Sequential 0,1,2,0,1,2...
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get action for training pattern
|
|
149
|
+
* @param {number} pattern - Training pattern (0-3)
|
|
150
|
+
* @param {number} episode - Current episode number
|
|
151
|
+
* @param {number} lastAction - Last action taken
|
|
152
|
+
* @returns {number} Action to take
|
|
153
|
+
*/
|
|
154
|
+
export function getTrainingAction(pattern, episode, lastAction = 0) {
|
|
155
|
+
switch (pattern) {
|
|
156
|
+
case TrainingPattern.ALWAYS_ROCK:
|
|
157
|
+
return 0;
|
|
158
|
+
case TrainingPattern.COUNTER:
|
|
159
|
+
return episode === 0 ? 0 : (lastAction + 2) % 3;
|
|
160
|
+
case TrainingPattern.SEQUENTIAL:
|
|
161
|
+
return episode % 3;
|
|
162
|
+
case TrainingPattern.RANDOM:
|
|
163
|
+
default:
|
|
164
|
+
return Math.floor(Math.random() * 3);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default RockPaperScissorsEnv;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSJI - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Public API exports for the reinforcement learning framework.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Core
|
|
8
|
+
export { Env, StateEncoder } from './core/env.js';
|
|
9
|
+
export { QLearning } from './core/qlearning.js';
|
|
10
|
+
export { Agent } from './core/agent.js';
|
|
11
|
+
|
|
12
|
+
// Storage
|
|
13
|
+
export { Storage, createStorage } from './storage/index.js';
|
|
14
|
+
export { SqliteStorage } from './storage/sqlite.js';
|
|
15
|
+
export { BetterSqliteStorage } from './storage/better-sqlite.js';
|
|
16
|
+
export { MemoryStorage } from './storage/memory.js';
|
|
17
|
+
|
|
18
|
+
// Environments
|
|
19
|
+
export { RockPaperScissorsEnv, TrainingPattern, getTrainingAction } from './envs/rps.js';
|
|
20
|
+
|
|
21
|
+
// Version
|
|
22
|
+
export const VERSION = '0.1.0';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better-SQLite3 Storage Implementation
|
|
3
|
+
*
|
|
4
|
+
* High-performance synchronous SQLite using better-sqlite3 package.
|
|
5
|
+
* Install with: npm install better-sqlite3
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Storage } from './index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* BetterSqliteStorage - Uses better-sqlite3
|
|
12
|
+
* Falls back gracefully if package not installed
|
|
13
|
+
*/
|
|
14
|
+
export class BetterSqliteStorage extends Storage {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} path - Database file path
|
|
17
|
+
*/
|
|
18
|
+
constructor(path = './lsji.db') {
|
|
19
|
+
super();
|
|
20
|
+
this.path = path;
|
|
21
|
+
this.db = null;
|
|
22
|
+
this.Database = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async initialize() {
|
|
26
|
+
try {
|
|
27
|
+
// Dynamic import to avoid hard dependency
|
|
28
|
+
const module = await import('better-sqlite3');
|
|
29
|
+
this.Database = module.default;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw new Error('better-sqlite3 not installed. Run: npm install better-sqlite3');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.db = new this.Database(this.path);
|
|
35
|
+
|
|
36
|
+
// Create tables (same schema as sqlite.js)
|
|
37
|
+
this.db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
39
|
+
key TEXT PRIMARY KEY,
|
|
40
|
+
value TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS battle_history (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
mode TEXT NOT NULL CHECK (mode IN ('train', 'test')),
|
|
46
|
+
hand_a INTEGER NOT NULL,
|
|
47
|
+
hand_b INTEGER NOT NULL,
|
|
48
|
+
reward INTEGER NOT NULL,
|
|
49
|
+
created_at TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS q_table (
|
|
53
|
+
state TEXT NOT NULL,
|
|
54
|
+
action INTEGER NOT NULL,
|
|
55
|
+
q_value REAL NOT NULL DEFAULT 0,
|
|
56
|
+
PRIMARY KEY (state, action)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_battle_history_date
|
|
60
|
+
ON battle_history(created_at);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_battle_history_mode
|
|
62
|
+
ON battle_history(mode);
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
// Initialize default settings
|
|
66
|
+
const stmt = this.db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
|
67
|
+
stmt.run('is_active', '1');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async close() {
|
|
71
|
+
if (this.db) {
|
|
72
|
+
this.db.close();
|
|
73
|
+
this.db = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getSetting(key) {
|
|
78
|
+
const stmt = this.db.prepare('SELECT key, value FROM settings WHERE key = ?');
|
|
79
|
+
const row = stmt.get(key);
|
|
80
|
+
return row ? { key: row.key, value: row.value } : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async setSetting(key, value) {
|
|
84
|
+
const stmt = this.db.prepare(`
|
|
85
|
+
INSERT INTO settings (key, value) VALUES (?, ?)
|
|
86
|
+
ON CONFLICT(key) DO UPDATE SET value = ?
|
|
87
|
+
`);
|
|
88
|
+
stmt.run(key, String(value), String(value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getQTable() {
|
|
92
|
+
const stmt = this.db.prepare('SELECT state, action, q_value FROM q_table ORDER BY state, action');
|
|
93
|
+
return stmt.all();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async updateQ(state, action, qValue) {
|
|
97
|
+
const stmt = this.db.prepare(`
|
|
98
|
+
INSERT INTO q_table (state, action, q_value) VALUES (?, ?, ?)
|
|
99
|
+
ON CONFLICT(state, action) DO UPDATE SET q_value = ?
|
|
100
|
+
`);
|
|
101
|
+
stmt.run(state, action, qValue, qValue);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async addBattle(record) {
|
|
105
|
+
const stmt = this.db.prepare(`
|
|
106
|
+
INSERT INTO battle_history (mode, hand_a, hand_b, reward, created_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?)
|
|
108
|
+
`);
|
|
109
|
+
stmt.run(record.mode, record.handA, record.handB, record.reward, record.createdAt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getTodayBattleCount() {
|
|
113
|
+
const today = new Date().toISOString().split('T')[0];
|
|
114
|
+
const stmt = this.db.prepare(`
|
|
115
|
+
SELECT COUNT(*) as count FROM battle_history
|
|
116
|
+
WHERE date(created_at) = date(?)
|
|
117
|
+
`);
|
|
118
|
+
const row = stmt.get(today);
|
|
119
|
+
return row ? row.count : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getPerformanceStats() {
|
|
123
|
+
const stmt = this.db.prepare(`
|
|
124
|
+
SELECT
|
|
125
|
+
mode,
|
|
126
|
+
COUNT(*) as total,
|
|
127
|
+
ROUND(AVG(CASE WHEN reward > 0 THEN 1.0 ELSE 0.0 END) * 100, 1) as win_rate
|
|
128
|
+
FROM battle_history
|
|
129
|
+
GROUP BY mode
|
|
130
|
+
`);
|
|
131
|
+
return stmt.all();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Abstraction Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for all storage backends.
|
|
5
|
+
* Implementations: SqliteStorage, BetterSqliteStorage, MemoryStorage
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Battle record
|
|
10
|
+
* @typedef {Object} BattleRecord
|
|
11
|
+
* @property {'train'|'test'} mode - Battle mode
|
|
12
|
+
* @property {number} handA - AI's action
|
|
13
|
+
* @property {number} handB - Opponent's action
|
|
14
|
+
* @property {number} reward - Reward (-1, 0, 1)
|
|
15
|
+
* @property {string} createdAt - ISO timestamp
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Q-table record
|
|
20
|
+
* @typedef {Object} QTableRecord
|
|
21
|
+
* @property {string} state - State key
|
|
22
|
+
* @property {number} action - Action
|
|
23
|
+
* @property {number} qValue - Q-value
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Performance stat by mode
|
|
28
|
+
* @typedef {Object} PerformanceStat
|
|
29
|
+
* @property {string} mode - 'train' or 'test'
|
|
30
|
+
* @property {number} total - Total battles
|
|
31
|
+
* @property {number} winRate - Win rate percentage
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Abstract Storage Interface
|
|
36
|
+
* All storage backends must implement these methods.
|
|
37
|
+
*/
|
|
38
|
+
export class Storage {
|
|
39
|
+
/**
|
|
40
|
+
* Initialize the storage (create tables, connections, etc.)
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
async initialize() {
|
|
44
|
+
throw new Error('initialize() must be implemented');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Close the storage connection
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
async close() {
|
|
52
|
+
throw new Error('close() must be implemented');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get a setting value
|
|
57
|
+
* @param {string} key - Setting key
|
|
58
|
+
* @returns {Promise<{key: string, value: string}|null>}
|
|
59
|
+
*/
|
|
60
|
+
async getSetting(key) {
|
|
61
|
+
throw new Error('getSetting() must be implemented');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Set a setting value
|
|
66
|
+
* @param {string} key - Setting key
|
|
67
|
+
* @param {string|number} value - Setting value
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
async setSetting(key, value) {
|
|
71
|
+
throw new Error('setSetting() must be implemented');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get all Q-table records
|
|
76
|
+
* @returns {Promise<Array<QTableRecord>>}
|
|
77
|
+
*/
|
|
78
|
+
async getQTable() {
|
|
79
|
+
throw new Error('getQTable() must be implemented');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Update Q-value for state-action pair
|
|
84
|
+
* @param {string} state - State key
|
|
85
|
+
* @param {number} action - Action
|
|
86
|
+
* @param {number} qValue - Q-value
|
|
87
|
+
* @returns {Promise<void>}
|
|
88
|
+
*/
|
|
89
|
+
async updateQ(state, action, qValue) {
|
|
90
|
+
throw new Error('updateQ() must be implemented');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add a battle record
|
|
95
|
+
* @param {BattleRecord} record - Battle record
|
|
96
|
+
* @returns {Promise<void>}
|
|
97
|
+
*/
|
|
98
|
+
async addBattle(record) {
|
|
99
|
+
throw new Error('addBattle() must be implemented');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get today's battle count
|
|
104
|
+
* @returns {Promise<number>}
|
|
105
|
+
*/
|
|
106
|
+
async getTodayBattleCount() {
|
|
107
|
+
throw new Error('getTodayBattleCount() must be implemented');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get performance statistics by mode
|
|
112
|
+
* @returns {Promise<Array<PerformanceStat>>}
|
|
113
|
+
*/
|
|
114
|
+
async getPerformanceStats() {
|
|
115
|
+
throw new Error('getPerformanceStats() must be implemented');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create storage instance by type
|
|
121
|
+
* @param {'sqlite'|'better-sqlite'|'memory'} type - Storage type
|
|
122
|
+
* @param {Object} options - Storage options
|
|
123
|
+
* @returns {Promise<Storage>} Initialized storage instance
|
|
124
|
+
*/
|
|
125
|
+
export async function createStorage(type, options = {}) {
|
|
126
|
+
let storage;
|
|
127
|
+
|
|
128
|
+
switch (type) {
|
|
129
|
+
case 'sqlite':
|
|
130
|
+
const { SqliteStorage } = await import('./sqlite.js');
|
|
131
|
+
storage = new SqliteStorage(options.path || './lsji.db');
|
|
132
|
+
break;
|
|
133
|
+
case 'better-sqlite':
|
|
134
|
+
const { BetterSqliteStorage } = await import('./better-sqlite.js');
|
|
135
|
+
storage = new BetterSqliteStorage(options.path || './lsji.db');
|
|
136
|
+
break;
|
|
137
|
+
case 'memory':
|
|
138
|
+
default:
|
|
139
|
+
const { MemoryStorage } = await import('./memory.js');
|
|
140
|
+
storage = new MemoryStorage();
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await storage.initialize();
|
|
145
|
+
return storage;
|
|
146
|
+
}
|