diff --git a/README.md b/README.md index a90bb53..4961cfc 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,91 @@ -# これはなにか +# 箱庭諸島 -箱庭諸島2をベースに、今風のPerlで動くようにする版です。 +箱庭諸島 ver2.3 の現代的な実装プロジェクトです。 -# ライセンス +## 概要 -オリジナルの箱庭諸島2に準じます。 +本プロジェクトは以下の2つの実装を含みます: + +1. **Perl版** (`perl/`) - Plack/PSGIを使用した現代的なPerl実装 +2. **Go版** (`go/`) - Perl版からのポート(移植中) + +## プロジェクト構成 + +``` +hakoniwa/ +├── perl/ # Perl実装(現行版) +│ ├── cgi/ # CGIスクリプト +│ ├── lib/Hako/ # コアモジュール +│ ├── t/ # テスト +│ └── app.psgi # Plackアプリケーション +├── go/ # Go実装(移植版) +│ ├── cmd/ # エントリーポイント +│ ├── internal/ # 内部パッケージ +│ └── test/ # テスト +└── docs/ # ドキュメント + ├── migration/ # 移植関連ドキュメント + └── original/ # オリジナルドキュメント +``` + +## クイックスタート + +### Perl版を実行 + +```bash +cd perl +plackup app.psgi +# http://localhost:5000 でアクセス +``` + +### Go版を実行(移植完了後) + +```bash +cd go +make build +./bin/hako-main +# http://localhost:8080 でアクセス +``` + +## Go移植プロジェクト + +### 移植計画 + +詳細は [移植計画ドキュメント](docs/migration/MIGRATION_PLAN.md) を参照してください。 + +**フェーズ:** +- **Phase 0**: 準備・設計 ✅ +- **Phase 1**: 逐語的移植(進行中) +- **Phase 2**: Go慣用的リファクタリング(予定) +- **Phase 3**: 最適化・クリーンアップ(予定) + +### ドキュメント + +- [移植計画](docs/migration/MIGRATION_PLAN.md) - 全体計画と手順 +- [Perl→Goマッピング](docs/migration/PERL_TO_GO_MAPPING.md) - コード変換対応表 +- [データフォーマット仕様](docs/migration/DATA_FORMAT.md) - ファイル形式 +- [APIリファレンス](docs/migration/API_REFERENCE.md) - 内部API設計 +- [テスト戦略](docs/migration/TESTING_STRATEGY.md) - テスト方針 + +## ライセンス + +オリジナルの箱庭諸島 ver2.3 に準じます。 素晴らしいゲームを制作された原作者の方々に敬意を表します。 > 箱庭諸島 ver2.3 -> +> > 字: 徳岡宏樹 > 絵: 小川克人 > 題字: 稲葉修吾 > テストプレイ他協力: 井上友博、小澤武史、さかもと、ほえほえ、ありづか > 箱庭諸島のページ: http://www.bekkoame.ne.jp/~tokuoka/hakoniwa.html -## スクリプトについて +### スクリプトについて 以下、オリジナルのreadme.txtより。 > 箱庭諸島2のスクリプトを改変し、それを他人に譲渡、配布する場合には、 > 以下の制約を課します。 -> +> > ・無料配布であること。 > ・ゲーム画面のトップに表示される、スクリプトの配布元へのリンクを > 消すのを禁止すること。また、それ以外の改造は許可すること。 @@ -32,12 +96,12 @@ …といっても、オリジナルの作者である[徳岡宏樹さんのWebサイト](http://t.pos.to/hako/)は 現時点でアクセスできない状態になってしまっているため、4つめの制約はあまり意味のないものになってしまっています。 -## 画像ファイルについて +### 画像ファイルについて 以下、[徳岡宏樹さんのWebサイトサイトのアーカイブ](https://web.archive.org/web/20070113153728/http://t.pos.to/hako/)より。 > [Q3] オリジナルに付属していた画像については、再配布や改変は可能ですか? -> +> > [A3] 商用利用を除いて、許可するものとします。 > オリジナルスクリプトに付属していた文書では「箱庭諸島以外の用途に使用してはならない」と書いてありました。 > しかし、その後原作者より「商用でない限り、箱庭諸島以外でも配布・改変可」という許可を得ています。 @@ -45,7 +109,7 @@ > もちろん何らかの問題が発生したとしても画像の原作者は関知しません。 > 自己責任でお願いします。 -# 参考にさせていただいたWebサイト +## 参考にさせていただいたWebサイト * 再配布 * [箱庭諸島の保管庫](http://www.hakoniwa.net/hako/) @@ -53,6 +117,18 @@ * [Neo-INO](http://neo-sub.sakura.ne.jp/ino/hako/download.html) * 解説 * [箱庭解体新書](http://qqmh3psd.web.fc2.com/sadoga/) -* jcode.pl - * [ftp.iij.ad.jp](ftp://ftp.iij.ad.jp/pub/IIJ/dist/utashiro/perl/) +## 貢献 + +プルリクエストを歓迎します!移植プロジェクトへの貢献については、[移植計画](docs/migration/MIGRATION_PLAN.md)を参照してください。 + +## 開発環境 + +### Perl版 +- Perl 5.x +- Plack/PSGI +- 依存モジュール: `cpanfile` 参照 + +### Go版 +- Go 1.21以上 +- 依存モジュール: `go.mod` 参照 diff --git a/docs/migration/API_REFERENCE.md b/docs/migration/API_REFERENCE.md new file mode 100644 index 0000000..da5fc01 --- /dev/null +++ b/docs/migration/API_REFERENCE.md @@ -0,0 +1,787 @@ +# API リファレンス + +本ドキュメントは、箱庭諸島Go版の内部APIを定義します。Phase 1(逐語的移植)とPhase 2(リファクタリング後)の両方の設計を記載します。 + +--- + +## 目次 + +1. [Phase 1 API(逐語的移植)](#phase-1-api逐語的移植) +2. [Phase 2 API(リファクタリング後)](#phase-2-apiリファクタリング後) +3. [パッケージ一覧](#パッケージ一覧) +4. [共通型定義](#共通型定義) + +--- + +## Phase 1 API(逐語的移植) + +Phase 1では、Perlの構造をそのまま維持し、グローバル変数を使用します。 + +### パッケージ構成 + +``` +internal/hako/ +├── const/ # 定数 +├── variable/ # グローバル変数 +├── core/ # コアロジック +├── turn/ # ターン処理 +├── mapview/ # 島の表示 +├── top/ # トップページ +└── maintenance/ # メンテナンス +``` + +--- + +### const パッケージ + +**ファイル:** `internal/hako/const/const.go` + +#### 設定定数 + +```go +package const + +// ディレクトリ・パス設定 +const ( + BaseDir = "" + ImageDir = "/images" + DirName = "data" + DirMode = 0755 +) + +// 認証設定 +const ( + MasterPassword = "yourpassword" + SpecialPassword = "" + AdminName = "管理者" + Email = "admin@example.com" +) + +// ゲーム設定 +const ( + UnitTime = 21600 // 6時間(秒) + MaxIsland = 30 // 最大島数 + TopLogTurn = 30 // トップページに表示するログのターン数 + LogMax = 10 // ログファイルの最大数 + BackupTurn = 10 // バックアップ間隔(ターン) + BackupTimes = 4 // バックアップ世代数 + HistoryMax = 100 // 履歴の最大行数 + GiveupTurn = 24 // 放棄判定ターン数 + CommandMax = 30 // コマンドキューの最大数 + IslandSize = 11 // 島のサイズ(11x11) +) + +// 初期値 +const ( + InitialMoney = 100 // 初期資金(100億円単位) + InitialFood = 50 // 初期食料(100トン単位) +) + +// 単位 +const ( + UnitMoney = 100 // 資金の単位(億円) + UnitFood = 100 // 食料の単位(トン) + UnitPop = 100 // 人口の単位(人) + UnitArea = 100 // 面積の単位(平方km) +) +``` + +#### 地形定数 + +```go +// 地形タイプ +const ( + LandSea = 0 // 海 + LandWaste = 1 // 荒地 + LandPlains = 2 // 平地 + LandTown = 3 // 町 + LandForest = 4 // 森 + LandFarm = 5 // 農場 + LandFactory = 6 // 工場 + LandBase = 7 // ミサイル基地 + LandSbase = 8 // 海底基地 + LandDefence = 9 // 防衛施設 + LandMountain = 10 // 山 + LandMonster = 11 // モンスター + LandSbase2 = 12 // 海底都市 + LandOil = 13 // 海底油田 + LandMonument = 14 // 記念碑 +) +``` + +#### 災害・モンスター定数 + +```go +// 災害確率 +const ( + DisEarthquake = 10 // 地震 + DisTsunami = 10 // 津波 + DisTyphoon = 10 // 台風 + DisMeteo = 5 // 隕石 + DisHugeMeteo = 10 // 巨大隕石 + DisEruption = 5 // 噴火 + DisFire = 20 // 火災 +) + +// モンスター種類 +const ( + MonsterNumber = 9 +) + +// モンスター名 +var MonsterName = []string{ + "いのら", + "サンジラ", + "レッドいのら", + "ダークいのら", + "いのらゴースト", + "クジラ", + "キングいのら", + "メカいのら", + "くじらず", +} + +// モンスターHP +var MonsterBHP = []int{1, 1, 2, 3, 1, 10, 5, 3, 20} +var MonsterDHP = []int{1, 2, 2, 3, 1, 10, 5, 5, 30} + +// モンスター特殊能力 +var MonsterSpecial = []int{0, 0, 1, 2, 3, 4, 5, 6, 7} + +// モンスター経験値 +var MonsterExp = []int{100, 200, 300, 500, 10, 1000, 800, 500, 2000} +``` + +--- + +### variable パッケージ + +**ファイル:** `internal/hako/variable/variable.go` + +#### グローバル変数 + +```go +package variable + +import "bytes" + +// リクエストパラメータ +var ( + Mode string // 処理モード + CurrentName string // 島の名前(入力) + CurrentID string // 島のID(入力) + DefaultID string // デフォルトID + OldPassword string // 旧パスワード + InputPassword string // 新パスワード + InputPassword2 string // 新パスワード(確認) + Message string // メッセージ +) + +// コマンド関連 +var ( + CommandPlanNumber int // コマンド番号 + CommandKind int // コマンド種類 + CommandTarget string // ターゲットID + CommandX int // X座標 + CommandY int // Y座標 + CommandArg int // 引数 + CommandMode string // コマンドモード(write/insert/delete) +) + +// ゲーム状態 +var ( + Turn string // 現在のターン + IslandNumber string // 島の総数 + IslandLastID string // 最後のID + Islands []Island // 島データ + IDToName map[string]string // ID → 名前 + IDToNumber map[string]int // ID → 番号 +) + +// ログプール +var ( + LogPool []string // 通常ログ + LogPoolSecure []string // 機密ログ +) + +// 出力バッファ +var ( + OutputBuffer bytes.Buffer // HTML出力バッファ +) + +// 乱数座標配列 +var ( + Rpx [121]int // ランダムX座標 + Rpy [121]int // ランダムY座標 +) +``` + +--- + +### core パッケージ + +**ファイル:** `internal/hako/core/` + +#### core.go - エントリーポイント + +```go +package core + +// RunMain はメインエントリーポイント +// Ref: lib/Hako/Main.pm:65 +func RunMain(r *http.Request) +``` + +#### fileio.go - ファイルI/O + +```go +// ReadIslandsFile はメインデータファイルを読み込む +// Ref: lib/Hako/Main.pm:~200 +func ReadIslandsFile(id string) bool + +// ReadIsland は個別島データを読み込む +// Ref: lib/Hako/Main.pm:~300 +func ReadIsland(island *Island) bool + +// WriteIslandsFile はメインデータファイルに書き込む +// Ref: lib/Hako/Main.pm:~400 +func WriteIslandsFile() bool + +// WriteIsland は個別島データを書き込む +// Ref: lib/Hako/Main.pm:~450 +func WriteIsland(island *Island) bool +``` + +#### lock.go - ロック機構 + +```go +// hakoLock はファイルロックを取得 +// Ref: lib/Hako/Main.pm:~600 +func hakoLock() bool + +// unlock はファイルロックを解放 +// Ref: lib/Hako/Main.pm:~650 +func unlock() +``` + +#### utils.go - ユーティリティ + +```go +// htmlEscape はHTML特殊文字をエスケープ +// Ref: lib/Hako/Main.pm:~500 +func htmlEscape(str string) string + +// random は乱数を生成 [0, m) +// Ref: lib/Hako/Main.pm:~520 +func random(m int) int + +// encode はパスワードを暗号化 +// Ref: lib/Hako/Main.pm:~540 +func encode(password string) string + +// checkPassword はパスワードを検証 +// Ref: lib/Hako/Main.pm:~560 +func checkPassword(input, stored string) bool + +// expToLevel は経験値からレベルを計算 +// Ref: lib/Hako/Main.pm:~700 +func expToLevel(exp int) int + +// monsterSpec はモンスターの仕様を取得 +// Ref: lib/Hako/Main.pm:~720 +func monsterSpec(lv int) (name string, hp int, exp int) +``` + +#### template.go - HTMLテンプレート + +```go +// tempHeader はHTMLヘッダーを出力 +// Ref: lib/Hako/Main.pm:~800 +func tempHeader() + +// tempFooter はHTMLフッターを出力 +// Ref: lib/Hako/Main.pm:~850 +func tempFooter() + +// tempLockFail はロック失敗メッセージを出力 +// Ref: lib/Hako/Main.pm:~900 +func tempLockFail() + +// tempNoDataFile はデータファイルなしメッセージを出力 +// Ref: lib/Hako/Main.pm:~920 +func tempNoDataFile() + +// tempWrongPassword はパスワード誤りメッセージを出力 +// Ref: lib/Hako/Main.pm:~940 +func tempWrongPassword() +``` + +--- + +### turn パッケージ + +**ファイル:** `internal/hako/turn/` + +#### turn.go + +```go +package turn + +// NewIslandMain は新しい島を作成 +// Ref: lib/Hako/Turn.pm:~50 +func NewIslandMain() + +// TurnMain はターン処理を実行 +// Ref: lib/Hako/Turn.pm:~500 +func TurnMain() + +// ChangeMain は島の情報を変更 +// Ref: lib/Hako/Turn.pm:~200 +func ChangeMain() +``` + +--- + +### mapview パッケージ + +**ファイル:** `internal/hako/mapview/mapview.go` + +```go +package mapview + +// PrintIslandMain は島を観光表示 +// Ref: lib/Hako/Map.pm:~50 +func PrintIslandMain() + +// OwnerMain は島の開発画面を表示 +// Ref: lib/Hako/Map.pm:~200 +func OwnerMain() + +// CommandMain はコマンドを登録 +// Ref: lib/Hako/Map.pm:~400 +func CommandMain() + +// CommentMain はコメントを更新 +// Ref: lib/Hako/Map.pm:~600 +func CommentMain() +``` + +--- + +### top パッケージ + +**ファイル:** `internal/hako/top/top.go` + +```go +package top + +// TopPageMain はトップページを表示 +// Ref: lib/Hako/Top.pm:~50 +func TopPageMain() +``` + +--- + +### maintenance パッケージ + +**ファイル:** `internal/hako/maintenance/maintenance.go` + +```go +package maintenance + +// RunMaintenance はメンテナンスツールを実行 +// Ref: lib/Hako/Maintenance.pm:~50 +func RunMaintenance(r *http.Request) +``` + +--- + +## Phase 2 API(リファクタリング後) + +Phase 2では、グローバル変数を排除し、構造体ベースの設計に移行します。 + +### 設計方針 + +- グローバル変数 → 構造体フィールド +- 関数 → メソッド +- インターフェースの導入 +- エラーハンドリングの改善 +- コンテキスト対応 + +--- + +### コアインターフェース + +```go +// Storage はデータ永続化のインターフェース +type Storage interface { + ReadIslands(ctx context.Context) ([]Island, error) + WriteIslands(ctx context.Context, islands []Island) error + ReadIsland(ctx context.Context, id string) (*Island, error) + WriteIsland(ctx context.Context, island *Island) error + ReadHistory(ctx context.Context) ([]HistoryEntry, error) + WriteHistory(ctx context.Context, entries []HistoryEntry) error +} + +// LockManager はロック管理のインターフェース +type LockManager interface { + Lock(ctx context.Context) error + Unlock(ctx context.Context) error +} + +// Logger はログ出力のインターフェース +type Logger interface { + Info(msg string, fields ...Field) + Error(msg string, err error, fields ...Field) +} +``` + +--- + +### Game 構造体(Phase 2のコア) + +```go +package game + +import ( + "context" + "hakoniwa/internal/hako/const" +) + +// Game はゲームのメインロジックを管理 +type Game struct { + config *Config + storage Storage + lockMgr LockManager + logger Logger + rand *rand.Rand +} + +// NewGame は新しいGameインスタンスを作成 +func NewGame(cfg *Config, storage Storage, lockMgr LockManager, logger Logger) *Game { + return &Game{ + config: cfg, + storage: storage, + lockMgr: lockMgr, + logger: logger, + rand: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// HandleRequest はHTTPリクエストを処理 +func (g *Game) HandleRequest(ctx context.Context, req *Request) (*Response, error) { + // ロック取得 + if err := g.lockMgr.Lock(ctx); err != nil { + return nil, fmt.Errorf("failed to acquire lock: %w", err) + } + defer g.lockMgr.Unlock(ctx) + + // データ読み込み + islands, err := g.storage.ReadIslands(ctx) + if err != nil { + return nil, fmt.Errorf("failed to read islands: %w", err) + } + + // モード別処理 + var response *Response + switch req.Mode { + case "top": + response, err = g.handleTopPage(ctx, islands) + case "print": + response, err = g.handlePrintIsland(ctx, req, islands) + case "owner": + response, err = g.handleOwner(ctx, req, islands) + // ... + default: + response, err = g.handleTopPage(ctx, islands) + } + + if err != nil { + return nil, err + } + + return response, nil +} +``` + +--- + +### Request/Response 構造体 + +```go +// Request はHTTPリクエストを表現 +type Request struct { + Mode string + IslandName string + IslandID string + Password string + OldPassword string + Message string + Command *Command +} + +// Response はHTTPレスポンスを表現 +type Response struct { + StatusCode int + ContentType string + Body []byte + Cookies []*http.Cookie +} + +// Command はコマンドを表現 +type Command struct { + Number int + Kind int + Target string + X int + Y int + Arg int + Mode string // write/insert/delete +} +``` + +--- + +### Storage 実装 + +#### FileStorage(本番用) + +```go +package storage + +type FileStorage struct { + dataDir string + mu sync.RWMutex +} + +func NewFileStorage(dataDir string) *FileStorage { + return &FileStorage{dataDir: dataDir} +} + +func (fs *FileStorage) ReadIslands(ctx context.Context) ([]Island, error) { + fs.mu.RLock() + defer fs.mu.RUnlock() + + filepath := fmt.Sprintf("%s/hakojima.dat", fs.dataDir) + // 実装... +} + +func (fs *FileStorage) WriteIslands(ctx context.Context, islands []Island) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + filepath := fmt.Sprintf("%s/hakojima.dat", fs.dataDir) + // 実装... +} +``` + +#### MemoryStorage(テスト用) + +```go +package storage + +type MemoryStorage struct { + islands map[string]*Island + mu sync.RWMutex +} + +func NewMemoryStorage() *MemoryStorage { + return &MemoryStorage{ + islands: make(map[string]*Island), + } +} + +func (ms *MemoryStorage) ReadIslands(ctx context.Context) ([]Island, error) { + ms.mu.RLock() + defer ms.mu.RUnlock() + + result := make([]Island, 0, len(ms.islands)) + for _, island := range ms.islands { + result = append(result, *island) + } + return result, nil +} +``` + +--- + +## 共通型定義 + +すべてのフェーズで使用される型定義。 + +### Island 構造体 + +```go +// Island は島のデータを表現 +type Island struct { + // 基本情報 + Name string `json:"name"` + ID string `json:"id"` + Prize int `json:"prize"` + Absent int `json:"absent"` + Comment string `json:"comment"` + Password string `json:"-"` // JSONには含めない + + // リソース + Money int `json:"money"` + Food int `json:"food"` + Pop int `json:"pop"` + Area int `json:"area"` + + // 施設 + Farm int `json:"farm"` + Factory int `json:"factory"` + Mountain int `json:"mountain"` + + // 地形データ + Land [][]int `json:"land"` // 11x11 + LandValue [][]int `json:"land_value"` // 11x11 + + // コマンド・掲示板 + Commands []Command `json:"commands"` + Lbbs []LbbsEntry `json:"lbbs"` +} + +// NewIsland は新しい島を作成 +func NewIsland(name, id, password string) *Island { + island := &Island{ + Name: name, + ID: id, + Password: password, + Money: const.InitialMoney, + Food: const.InitialFood, + Area: const.IslandSize * const.IslandSize, + Land: make([][]int, const.IslandSize), + LandValue: make([][]int, const.IslandSize), + } + + // 地形初期化 + for i := 0; i < const.IslandSize; i++ { + island.Land[i] = make([]int, const.IslandSize) + island.LandValue[i] = make([]int, const.IslandSize) + } + + return island +} +``` + +### LandInfo 構造体 + +```go +// LandInfo は地形情報を表現 +type LandInfo struct { + Type int // 地形タイプ + Value int // 地形の値 +} +``` + +### Command 構造体 + +```go +// Command はコマンドを表現 +type Command struct { + Kind int `json:"kind"` + Target string `json:"target"` + X int `json:"x"` + Y int `json:"y"` + Arg int `json:"arg"` +} +``` + +### LbbsEntry 構造体 + +```go +// LbbsEntry はローカル掲示板のエントリーを表現 +type LbbsEntry struct { + Name string `json:"name"` + Message string `json:"message"` + Time int64 `json:"time"` +} +``` + +### HistoryEntry 構造体 + +```go +// HistoryEntry は履歴エントリーを表現 +type HistoryEntry struct { + Turn int `json:"turn"` + Message string `json:"message"` +} +``` + +### LogEntry 構造体 + +```go +// LogEntry は詳細ログエントリーを表現 +type LogEntry struct { + SecretFlag int `json:"secret_flag"` // 0=公開, 1=当事者のみ, 2=機密 + Turn int `json:"turn"` + ID1 string `json:"id1"` // 当事者 + ID2 string `json:"id2"` // 相手 + Message string `json:"message"` +} +``` + +--- + +## 使用例 + +### Phase 1 の使用例 + +```go +import ( + "net/http" + "hakoniwa/internal/hako/core" +) + +func handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + core.RunMain(r) + w.Write(variable.OutputBuffer.Bytes()) +} +``` + +### Phase 2 の使用例 + +```go +import ( + "context" + "net/http" + "hakoniwa/internal/game" + "hakoniwa/internal/storage" +) + +func handler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // 依存性注入 + storage := storage.NewFileStorage("data") + lockMgr := lock.NewFileLock("data/hakojima.lock") + logger := logger.NewStdLogger() + cfg := LoadConfig() + + game := game.NewGame(cfg, storage, lockMgr, logger) + + // リクエストパース + req := parseRequest(r) + + // 処理実行 + resp, err := game.HandleRequest(ctx, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // レスポンス送信 + w.Header().Set("Content-Type", resp.ContentType) + w.WriteHeader(resp.StatusCode) + w.Write(resp.Body) +} +``` + +--- + +## 参考資料 + +- [PERL_TO_GO_MAPPING.md](./PERL_TO_GO_MAPPING.md) - Perl/Go対応表 +- [DATA_FORMAT.md](./DATA_FORMAT.md) - データフォーマット仕様 diff --git a/docs/migration/DATA_FORMAT.md b/docs/migration/DATA_FORMAT.md new file mode 100644 index 0000000..72f51cc --- /dev/null +++ b/docs/migration/DATA_FORMAT.md @@ -0,0 +1,459 @@ +# データフォーマット仕様 + +本ドキュメントは、箱庭諸島のデータファイル形式を詳細に記述します。Perl版とGo版で**完全な互換性**を維持する必要があります。 + +--- + +## 目次 + +1. [ファイル一覧](#ファイル一覧) +2. [hakojima.dat - メインデータファイル](#hakojimadat---メインデータファイル) +3. [island.{ID} - 個別島データ](#islandid---個別島データ) +4. [hakojima.his - 履歴ファイル](#hakojimahis---履歴ファイル) +5. [hakojima.log{N} - 詳細ログファイル](#hakojimalogn---詳細ログファイル) +6. [ロックファイル](#ロックファイル) +7. [バックアップファイル](#バックアップファイル) +8. [エンコーディング](#エンコーディング) +9. [Go実装の注意点](#go実装の注意点) + +--- + +## ファイル一覧 + +すべてのデータファイルは `cgi/data/` ディレクトリに格納されます(設定により変更可能)。 + +| ファイル名 | 説明 | 形式 | +|----------|------|-----| +| `hakojima.dat` | メインデータ(ターン情報、全島の基本情報) | テキスト(カンマ区切り) | +| `island.{ID}` | 個別島データ(地形、コマンド、掲示板) | テキスト(Hex + カンマ区切り) | +| `hakojima.his` | ゲーム履歴ログ | テキスト(カンマ区切り) | +| `hakojima.log{0-9}` | 詳細ログファイル(10ファイルローテーション) | テキスト(カンマ区切り) | +| `hakojima.lock` | ロックファイル | 空ファイル(存在チェックのみ) | +| `data.bak{0-3}` | バックアップディレクトリ | ディレクトリ(ローテーション) | + +--- + +## hakojima.dat - メインデータファイル + +### フォーマット + +``` +<ヘッダー行> +<島データ行1> +<島データ行2> +... +<島データ行N> +``` + +### ヘッダー行 + +カンマ区切りで以下の情報を含みます: + +``` +ターン数,最終更新時間,島の総数,次に割り当てるID +``` + +| フィールド | 変数名 | 型 | 説明 | +|----------|--------|---|------| +| ターン数 | `$HislandTurn` | 整数 | 現在のターン番号(0から開始) | +| 最終更新時間 | `$HislandLastTime` | UNIX時刻 | 最後にターンが更新された時刻 | +| 島の総数 | `$HislandNumber` | 整数 | 現在存在する島の数 | +| 次に割り当てるID | `$HislandNextID` | 整数 | 新しい島に割り当てるID(連番) | + +**例:** +``` +123,1699876543,5,6 +``` +- ターン123 +- 最終更新: 2023-11-13 12:34:03 (UNIX時刻) +- 島の総数: 5 +- 次ID: 6 + +### 島データ行 + +各島ごとに1行。カンマ区切りで以下の情報を含みます: + +``` +名前,ID,受賞,連続資金繰り数,コメント,暗号化パスワード,資金,食料,人口,広さ,農場,工場,採掘場 +``` + +| # | フィールド | 変数名 | 型 | 説明 | +|--|----------|--------|---|------| +| 0 | 名前 | `$island->[0]` | 文字列 | 島の名前(最大32文字) | +| 1 | ID | `$island->[1]` | 整数 | 島の一意なID | +| 2 | 受賞 | `$island->[2]` | 整数 | 受賞フラグ(ビットマスク) | +| 3 | 連続資金繰り数 | `$island->[3]` | 整数 | 連続で放置されているターン数 | +| 4 | コメント | `$island->[4]` | 文字列 | 島の説明(最大80文字) | +| 5 | 暗号化パスワード | `$island->[5]` | 文字列 | encode()関数で暗号化されたパスワード | +| 6 | 資金 | `$island->[6]` | 整数 | 現在の資金(100億円単位) | +| 7 | 食料 | `$island->[7]` | 整数 | 現在の食料(100トン単位) | +| 8 | 人口 | `$island->[8]` | 整数 | 現在の人口(100人単位) | +| 9 | 広さ | `$island->[9]` | 整数 | 島の面積(100平方km単位) | +| 10 | 農場 | `$island->[10]` | 整数 | 農場の数 | +| 11 | 工場 | `$island->[11]` | 整数 | 工場の数 | +| 12 | 採掘場 | `$island->[12]` | 整数 | 採掘場の数 | + +**例:** +``` +テスト島,0,0,0,平和な島です,xYz123,100,50,20,121,5,3,0 +``` + +### Go実装例 + +```go +type Island struct { + Name string // [0] 名前 + ID string // [1] ID + Prize int // [2] 受賞 + Absent int // [3] 連続資金繰り数 + Comment string // [4] コメント + Password string // [5] 暗号化パスワード + Money int // [6] 資金 + Food int // [7] 食料 + Pop int // [8] 人口 + Area int // [9] 広さ + Farm int // [10] 農場 + Factory int // [11] 工場 + Mountain int // [12] 採掘場 + + // island.{ID}ファイルから読み込まれるデータ + Land [][]LandInfo // 地形データ (11x11) + LandValue [][]int // 地形の値 (11x11) + Commands []Command // コマンドキュー + Lbbs []LbbsEntry // ローカル掲示板 +} +``` + +--- + +## island.{ID} - 個別島データ + +各島ごとに `island.0`, `island.1`, ... のファイルが作成されます。 + +### フォーマット + +``` +<地形データ(1行、363文字)> +<コマンド行1> +<コマンド行2> +... +<コマンド行N> (最大 HcommandMax 行) +<ローカル掲示板行1> +<ローカル掲示板行2> +... +``` + +### 地形データ(1行目) + +11x11 = 121ヘクスの地形情報を16進数で表現。**363文字**の固定長。 + +**フォーマット:** 各ヘクスは3文字 +``` + +``` + +- **land** (1桁): 地形タイプ(0-9, A-F) +- **landValue** (2桁): 地形の値(00-FF、16進数) + +**例:** +``` +000000000101010202030405060708090A0B0C0D0E0F0...(363文字) +``` + +| 位置 | 文字 | 意味 | +|-----|------|------| +| 0-2 | `000` | (0, 0) = 地形0, 値0 | +| 3-5 | `000` | (0, 1) = 地形0, 値0 | +| ... | ... | ... | +| 360-362 | `0F0` | (10, 10) = 地形0, 値F(15) | + +**読み取り順序:** 左上から右へ、行ごとに下へ(合計121ヘクス) + +**地形タイプ一覧:** + +| 値 | 定数名 | 地形 | +|----|--------|------| +| 0 | `$HlandSea` | 海 | +| 1 | `$HlandWaste` | 荒地 | +| 2 | `$HlandPlains` | 平地 | +| 3 | `$HlandTown` | 町 | +| 4 | `$HlandForest` | 森 | +| 5 | `$HlandFarm` | 農場 | +| 6 | `$HlandFactory` | 工場 | +| 7 | `$HlandBase` | 基地 | +| 8 | `$HlandSbase` | 海底基地 | +| 9 | `$HlandDefence` | 防衛施設 | +| A | `$HlandMountain` | 山 | +| B | `$HlandMonster` | モンスター | +| C | `$HlandSbase2` | 海底都市 | +| D | `$HlandOil` | 海底油田 | +| E | `$HlandMonument` | 記念碑 | + +### コマンド行(2行目以降) + +カンマ区切りで5フィールド: + +``` +kind,target,x,y,arg +``` + +| フィールド | 説明 | 型 | +|----------|------|---| +| kind | コマンドの種類 | 整数 | +| target | ターゲットID(援助・ミサイルなど) | 整数 | +| x | X座標(0-10) | 整数 | +| y | Y座標(0-10) | 整数 | +| arg | 引数(数量など) | 整数 | + +**例:** +``` +0,0,5,5,0 +1,2,3,4,10 +``` + +**最大行数:** `$HcommandMax`(デフォルト30) + +### ローカル掲示板行 + +実装により異なる(詳細は `$HuseLbbs` による) + +--- + +## hakojima.his - 履歴ファイル + +ゲームの公開イベント履歴を保存します。 + +### フォーマット + +``` +<ターン数>,<ログメッセージ> +<ターン数>,<ログメッセージ> +... +``` + +**例:** +``` +120,テスト島で大地震が発生! +121,モンスター「いのら」がサンプル島に接近! +122,テスト島がサンプル島に援助を実施 +``` + +**最大行数:** `$HhistoryMax`(デフォルト100) + +--- + +## hakojima.log{N} - 詳細ログファイル + +10個のログファイル(`hakojima.log0` ~ `hakojima.log9`)にローテーションで記録されます。 + +### フォーマット + +``` +<機密フラグ>,<ターン数>,,,<メッセージ> +``` + +| フィールド | 説明 | +|----------|------| +| 機密フラグ | 0=公開, 1=当事者のみ, 2=機密 | +| ターン数 | イベント発生ターン | +| id1 | 当事者の島ID | +| id2 | 相手の島ID(該当しない場合は空) | +| メッセージ | ログメッセージ | + +**例:** +``` +0,120,0,,テスト島: 人口が増加 +1,121,0,1,テスト島 → サンプル島: ミサイル発射 +2,122,1,,サンプル島: 極秘作戦実行 +``` + +--- + +## ロックファイル + +### hakojima.lock + +並行アクセスを防ぐためのロックファイル。 + +**ロック方式:** + +| モード | 方式 | 説明 | +|-------|------|------| +| 0 | flock | ファイルロック(推奨) | +| 1 | symlink | シンボリックリンク | +| 2 | mkdir | ディレクトリ作成 | +| 3 | no lock | ロックなし(開発用) | + +**Go実装:** +```go +import "github.com/gofrs/flock" + +fileLock := flock.New("data/hakojima.lock") +locked, err := fileLock.TryLock() +if err != nil || !locked { + return fmt.Errorf("failed to acquire lock") +} +defer fileLock.Unlock() +``` + +--- + +## バックアップファイル + +### data.bak{0-3} + +定期的に `data/` ディレクトリ全体をバックアップ。 + +**ローテーション:** +- `$HbackupTurn` ターンごとにバックアップ(デフォルト: 10ターン) +- `$HbackupTimes` 個のバックアップを保持(デフォルト: 4) + +**ディレクトリ構造:** +``` +data/ # 現在のデータ +data.bak0/ # 最新のバックアップ +data.bak1/ # 1つ前 +data.bak2/ # 2つ前 +data.bak3/ # 3つ前(最古) +``` + +--- + +## エンコーディング + +### 文字コード + +- **ファイル:** UTF-8 +- **Perl:** `use utf8; use open ':encoding(utf8)';` +- **Go:** すべて UTF-8(標準) + +### パスワード暗号化 + +**Perl実装 (`encode` 関数):** +```perl +sub encode { + my ($password) = @_; + if ($cryptOn) { + return crypt($password, $password); + } else { + return $password; + } +} +``` + +**Go実装:** +```go +import "golang.org/x/crypto/bcrypt" + +func encode(password string) (string, error) { + if const.CryptOn { + // crypt() 互換の実装が必要 + // Perl の crypt() は DES ベースなので注意 + return cryptCompat(password, password) + } + return password, nil +} +``` + +**注意:** Perlの `crypt()` は環境依存。互換性のため、同じアルゴリズム(DES)を使用する必要があります。 + +--- + +## Go実装の注意点 + +### 1. ファイルI/Oの順序保証 + +Perlでは順次処理されるため、Go実装でも**同じ順序**でファイルを読み書きする必要があります。 + +### 2. 数値のフォーマット + +**16進数地形データ:** +```go +// 読み込み +landType := int(hexStr[0]) // '0'-'9', 'A'-'F' +landValue, _ := strconv.ParseInt(hexStr[1:3], 16, 64) + +// 書き込み +hexStr := fmt.Sprintf("%X%02X", landType, landValue) +``` + +### 3. カンマ区切りのパース + +```go +parts := strings.Split(line, ",") +if len(parts) != expectedCount { + return fmt.Errorf("invalid format") +} +``` + +**注意:** コメントや名前にカンマが含まれる可能性があるため、適切にエスケープ処理が必要。 + +### 4. ファイルパーミッション + +```go +// ディレクトリ +os.MkdirAll(dirPath, 0755) + +// ファイル +os.WriteFile(filePath, data, 0644) +``` + +### 5. アトミック書き込み + +```go +// 一時ファイルに書き込み → リネーム +tmpFile := filepath + ".tmp" +if err := os.WriteFile(tmpFile, data, 0644); err != nil { + return err +} +if err := os.Rename(tmpFile, filepath); err != nil { + return err +} +``` + +### 6. テストデータ生成 + +互換性テスト用に、Perl版と同じデータを生成できるようにする: + +```go +func GenerateTestIsland() Island { + return Island{ + Name: "テスト島", + ID: "0", + Prize: 0, + Absent: 0, + Comment: "平和な島です", + Password: "xYz123", // encode("test") + Money: 100, + Food: 50, + Pop: 20, + Area: 121, + Farm: 5, + Factory: 3, + Mountain: 0, + } +} +``` + +--- + +## チェックリスト + +データフォーマット互換性を確保するために: + +- [ ] ファイルのエンコーディングがUTF-8 +- [ ] カンマ区切りのフィールド数が正しい +- [ ] 16進数地形データが363文字固定 +- [ ] パスワード暗号化がPerl版と一致 +- [ ] ファイルロックが正しく動作 +- [ ] バックアップローテーションが正しい +- [ ] Perl版が生成したデータをGo版で読み込める +- [ ] Go版が生成したデータをPerl版で読み込める + +--- + +## 参考資料 + +- [memo.txt](../../perl/memo.txt) - オリジナルのメモ +- [Hako::Main::readIslandsFile](../../perl/lib/Hako/Main.pm) - Perl読み込み実装 +- [Hako::Main::writeIslandsFile](../../perl/lib/Hako/Main.pm) - Perl書き込み実装 diff --git a/docs/migration/MIGRATION_PLAN.md b/docs/migration/MIGRATION_PLAN.md new file mode 100644 index 0000000..7ae1c7a --- /dev/null +++ b/docs/migration/MIGRATION_PLAN.md @@ -0,0 +1,456 @@ +# 箱庭諸島 Perl → Go 移植計画 + +## 概要 + +本ドキュメントは、箱庭諸島(Hakoniwa)のPerlコードベース(7,400行)をGoに移植するための詳細な計画書です。 + +### 移植方針 + +1. **段階的アプローチ**: 逐語的移植 → Go慣用的リファクタリング → 最適化 +2. **互換性維持**: データフォーマット・ゲームロジックの完全な互換性を保証 +3. **テスト駆動**: 各段階で既存のPerl版との出力比較テストを実施 + +--- + +## フェーズ概要 + +``` +Phase 0: 準備・設計(1-2日) + ↓ +Phase 1: 逐語的移植(2-3週間) + ↓ +Phase 2: Go的リファクタリング(1-2週間) + ↓ +Phase 3: 最適化・クリーンアップ(1週間) +``` + +--- + +## Phase 0: 準備・設計(1-2日) + +### 目標 + +- 移植の基盤となるドキュメントとリポジトリ構造を整備 +- Perl → Go の対応関係を明確化 +- テスト戦略を確立 + +### 成果物 + +1. **ドキュメント** + - [x] `MIGRATION_PLAN.md` - 本ドキュメント + - [ ] `PERL_TO_GO_MAPPING.md` - Perl/Go対応表 + - [ ] `DATA_FORMAT.md` - データフォーマット仕様 + - [ ] `API_REFERENCE.md` - 内部API設計 + - [ ] `TESTING_STRATEGY.md` - テスト戦略 + +2. **リポジトリ構造** + - [ ] `go/` ディレクトリ配下の標準レイアウト作成 + - [ ] `perl/` ディレクトリへの既存コード移動 + - [ ] テスト用ディレクトリ構造の準備 + +3. **開発環境** + - [ ] `go.mod` 初期化 + - [ ] `Makefile` 作成 + - [ ] CI/CD設定(後日) + +--- + +## Phase 1: 逐語的移植(2-3週間) + +### 目標 + +- Perlコードを機械的にGoへ変換 +- 構造・ロジックを可能な限り元のまま維持 +- 各モジュールごとに互換性テストを実施 + +### 基本方針 + +#### DO ✅ + +- Perlの構造をそのまま維持 +- 関数名を機械的に変換(`snake_case` → `camelCase`) +- グローバル変数をそのまま使用(`variable/variable.go`) +- Perlのロジックを1:1で翻訳 +- コメントで元のPerl行番号を記録 + +#### DON'T ❌ + +- Phase 1で設計を改善しようとしない +- グローバル変数を排除しない(Phase 2で実施) +- インターフェースを抽出しない(Phase 2で実施) +- エラーハンドリングを改善しない(Phase 2で実施) + +### 実装順序 + +1. **依存関係の少ないモジュールから開始** + +``` +1. internal/hako/const (依存: なし) +2. internal/hako/variable (依存: const) +3. internal/hako/core (依存: const, variable) + - core.go (エントリーポイント) + - fileio.go (ファイルI/O) + - lock.go (ロック機構) + - utils.go (ユーティリティ) + - template.go (HTMLテンプレート) +4. internal/hako/turn (依存: const, variable, core) + - turn.go (ターン処理) + - disaster.go (災害処理) + - monster.go (モンスター処理) +5. internal/hako/mapview (依存: const, variable, core) +6. internal/hako/top (依存: const, variable, core) +7. internal/hako/maintenance (依存: const, variable, core) +8. internal/web (依存: 上記すべて) + - handler.go (HTTPハンドラ) + - middleware.go (ミドルウェア) +9. cmd/hako-main (エントリーポイント) +10. cmd/hako-mente (メンテナンスツール) +``` + +2. **各モジュールの実装手順** + +``` +For each module: + 1. Perlコードを読み、関数一覧を作成 + 2. Go構造体・型定義を作成 + 3. 各関数を逐語的に移植 + 4. 単体テストを作成 + 5. Perl版との出力比較テストを実行 + 6. パスするまで修正 + 7. 次のモジュールへ +``` + +### 命名規則 + +#### モジュール名 + +| Perl | Go | +|------|-----| +| `Hako::Main` | `internal/hako/core` | +| `Hako::Const` | `internal/hako/const` | +| `Hako::Variable` | `internal/hako/variable` | +| `Hako::Turn` | `internal/hako/turn` | +| `Hako::Map` | `internal/hako/mapview` | +| `Hako::Top` | `internal/hako/top` | +| `Hako::Maintenance` | `internal/hako/maintenance` | + +#### 関数名 + +| Perl | Go | 可視性 | +|------|-----|--------| +| `sub readIslandsFile` | `func ReadIslandsFile` | 公開 | +| `sub htmlEscape` | `func htmlEscape` | 非公開 | +| `sub hakolock` | `func hakoLock` | 非公開 | +| `sub cgiInput` | `func cgiInput` | 非公開 | + +**ルール:** +- 公開関数: 大文字開始(`ReadIslandsFile`) +- 非公開関数: 小文字開始(`htmlEscape`) + +#### 変数名 + +| Perl | Go | +|------|-----| +| `$HunitTime` | `const.UnitTime` | +| `$mode` | `variable.Mode` | +| `@Hislands` | `variable.Islands` | +| `%HidToName` | `variable.IDToName` | + +### 型変換パターン + +```go +// Perlハッシュ → Goマップ +my %hash = (); → hash := make(map[string]string) + +// Perl配列 → Goスライス +my @array = (); → array := []string{} + +// Perlリファレンス → Goポインタ +my $ref = \%hash; → ref := &hash + +// Perl島データ → Go構造体 +my @island = (...); → type Island struct { ... } +``` + +### エラーハンドリング(Phase 1) + +```go +// Phase 1: Perlのdieをそのままpanic +sub foo { + die "error" if !$ok; +} +↓ +func foo() { + if !ok { + panic("error") // Phase 1ではこれでOK + } +} +``` + +**注**: Phase 2で適切な `error` 型に改善 + +### テスト方針(Phase 1) + +#### 1. 単体テスト + +```go +// 各関数の基本動作を確認 +func TestHtmlEscape(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", + want: "<script>alert('XSS')</script>", + }, + { + name: "ダブルクォート", + input: `Say "Hello"`, + want: `Say "Hello"`, + }, + { + name: "全て混在", + input: `Link`, + want: `<a href="page.html?a=1&b=2">Link</a>`, + }, + { + name: "日本語とタグ", + input: "
こんにちは
", + want: "<div>こんにちは</div>", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HtmlEscape(tt.input) + if got != tt.want { + t.Errorf("HtmlEscape(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestCheckPassword tests the CheckPassword function +func TestCheckPassword(t *testing.T) { + // Set master password for testing + oldMaster := hconst.MasterPassword + hconst.MasterPassword = "master123" + defer func() { hconst.MasterPassword = oldMaster }() + + // Note: hconst.CryptOn is a const and cannot be changed in tests + // In Phase 1, cryptCompat returns password as-is, so testing works the same + + tests := []struct { + name string + stored string + input string + want bool + }{ + { + name: "正しいパスワード", + stored: "password123", + input: "password123", + want: true, + }, + { + name: "間違ったパスワード", + stored: "password123", + input: "wrongpassword", + want: false, + }, + { + name: "空のパスワード", + stored: "password123", + input: "", + want: false, + }, + { + name: "マスターパスワード", + stored: "password123", + input: "master123", + want: true, + }, + { + name: "大文字小文字区別", + stored: "Password", + input: "password", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CheckPassword(tt.stored, tt.input) + if got != tt.want { + t.Errorf("CheckPassword(%q, %q) = %v, want %v", tt.stored, tt.input, got, tt.want) + } + }) + } +} + +// TestMakeRandomPointArray tests the MakeRandomPointArray function +func TestMakeRandomPointArray(t *testing.T) { + // Initialize variable package + variable.Rpx = nil + variable.Rpy = nil + + MakeRandomPointArray() + + // Check that arrays are created + if variable.Rpx == nil { + t.Fatal("Rpx is nil after MakeRandomPointArray") + } + if variable.Rpy == nil { + t.Fatal("Rpy is nil after MakeRandomPointArray") + } + + // Check length + if len(variable.Rpx) != hconst.PointNumber { + t.Errorf("len(Rpx) = %d, want %d", len(variable.Rpx), hconst.PointNumber) + } + if len(variable.Rpy) != hconst.PointNumber { + t.Errorf("len(Rpy) = %d, want %d", len(variable.Rpy), hconst.PointNumber) + } + + // Check that all coordinates appear exactly once + coordMap := make(map[[2]int]int) + for i := 0; i < hconst.PointNumber; i++ { + x := variable.Rpx[i] + y := variable.Rpy[i] + + // Check range + if x < 0 || x >= hconst.IslandSize { + t.Errorf("Rpx[%d] = %d, out of range [0, %d)", i, x, hconst.IslandSize) + } + if y < 0 || y >= hconst.IslandSize { + t.Errorf("Rpy[%d] = %d, out of range [0, %d)", i, y, hconst.IslandSize) + } + + // Count occurrences + coord := [2]int{x, y} + coordMap[coord]++ + } + + // Check that all coordinates appear exactly once + if len(coordMap) != hconst.PointNumber { + t.Errorf("unique coordinates = %d, want %d", len(coordMap), hconst.PointNumber) + } + + for coord, count := range coordMap { + if count != 1 { + t.Errorf("coordinate %v appears %d times, want 1", coord, count) + } + } + + // Check that the result is shuffled (not in sequential order) + // This is a probabilistic test - it might fail rarely + sequential := true + expectedX := 0 + expectedY := 0 + for i := 0; i < hconst.PointNumber; i++ { + if variable.Rpx[i] != expectedX || variable.Rpy[i] != expectedY { + sequential = false + break + } + expectedX++ + if expectedX >= hconst.IslandSize { + expectedX = 0 + expectedY++ + } + } + + if sequential { + t.Log("Warning: Coordinates are in sequential order (might be random, but unlikely)") + } +} + +// TestMin tests the min function +func TestMin(t *testing.T) { + tests := []struct { + name string + a int + b int + want int + }{ + { + name: "a < b", + a: 5, + b: 10, + want: 5, + }, + { + name: "a > b", + a: 10, + b: 5, + want: 5, + }, + { + name: "a == b", + a: 7, + b: 7, + want: 7, + }, + { + name: "負の数", + a: -5, + b: 3, + want: -5, + }, + { + name: "0とゼロ", + a: 0, + b: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := min(tt.a, tt.b) + if got != tt.want { + t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// TestCutColumn tests the cutColumn function +func TestCutColumn(t *testing.T) { + tests := []struct { + name string + input string + column int + want string + }{ + { + name: "短い文字列", + input: "Hello", + column: 10, + want: "Hello", + }, + { + name: "ちょうど同じ長さ", + input: "12345", + column: 5, + want: "12345", + }, + { + name: "切り詰め", + input: "1234567890", + column: 5, + want: "12345", + }, + { + name: "日本語(UTF-8)", + input: "こんにちは世界", + column: 4, + want: "こんにち", + }, + { + name: "空文字列", + input: "", + column: 5, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cutColumn(tt.input, tt.column) + if got != tt.want { + t.Errorf("cutColumn(%q, %d) = %q, want %q", tt.input, tt.column, got, tt.want) + } + }) + } +} diff --git a/go/internal/hako/hconst/const.go b/go/internal/hako/hconst/const.go new file mode 100644 index 0000000..cffdd31 --- /dev/null +++ b/go/internal/hako/hconst/const.go @@ -0,0 +1,654 @@ +// Package const defines all constants and configuration values for the Hakoniwa game. +// This is a literal translation from Perl lib/Hako/Const.pm +// +// Ref: perl/lib/Hako/Const.pm +package hconst + +//---------------------------------------------------------------------- +// 各種設定値 +//---------------------------------------------------------------------- + +//---------------------------------------------------------------------- +// 必ず設定する部分 +//---------------------------------------------------------------------- + +// BaseDir はCGIファイルを置くディレクトリ +// Ref: perl/lib/Hako/Const.pm:192 +var BaseDir = "http://localhost:5000/cgi-bin" + +// ImageDir は画像ファイルを置くディレクトリ +// Ref: perl/lib/Hako/Const.pm:196 +var ImageDir = "http://localhost:5000/images" + +// MasterPassword はすべての島のパスワードを代用できるマスターパスワード +// Ref: perl/lib/Hako/Const.pm:201 +var MasterPassword = "yourpassword" + +// SpecialPassword は特殊パスワード(名前変更時に資金・食料が最大になる) +// Ref: perl/lib/Hako/Const.pm:206 +var SpecialPassword = "yourspecialpassword" + +// AdminName は管理者名 +// Ref: perl/lib/Hako/Const.pm:209 +var AdminName = "管理者の名前" + +// Email は管理者のメールアドレス +// Ref: perl/lib/Hako/Const.pm:212 +var Email = "管理者@どこか.どこか.どこか" + +// BBS は掲示板アドレス +// Ref: perl/lib/Hako/Const.pm:215 +var BBS = "http://サーバー/掲示板.cgi" + +// TopPage はホームページのアドレス +// Ref: perl/lib/Hako/Const.pm:218 +var TopPage = "http://サーバー/ホームページ.html" + +// DirMode はディレクトリのパーミッション +// Ref: perl/lib/Hako/Const.pm:222 +const DirMode = 0755 + +// DirName はデータディレクトリの名前 +// Ref: perl/lib/Hako/Const.pm:228 +var DirName = "data" + +// LockMode はロックの方式 +// 1: ディレクトリ, 2: システムコール(flock), 3: シンボリックリンク, 4: 通常ファイル +// Ref: perl/lib/Hako/Const.pm:237 +const LockMode = 2 + +//---------------------------------------------------------------------- +// ゲームの進行やファイルなど +//---------------------------------------------------------------------- + +// UnitTime は1ターンが何秒か (デフォルト: 6時間) +// Ref: perl/lib/Hako/Const.pm:254 +const UnitTime = 21600 + +// UnlockTime は異常終了基準時間(ロック後何秒で強制解除するか) +// Ref: perl/lib/Hako/Const.pm:258 +const UnlockTime = 120 + +// MaxIsland は島の最大数 +// Ref: perl/lib/Hako/Const.pm:261 +const MaxIsland = 30 + +// TopLogTurn はトップページに表示するログのターン数 +// Ref: perl/lib/Hako/Const.pm:264 +const TopLogTurn = 1 + +// LogMax はログファイル保持ターン数 +// Ref: perl/lib/Hako/Const.pm:267 +const LogMax = 8 + +// BackupTurn はバックアップを何ターンおきに取るか +// Ref: perl/lib/Hako/Const.pm:270 +const BackupTurn = 12 + +// BackupTimes はバックアップを何回分残すか +// Ref: perl/lib/Hako/Const.pm:273 +const BackupTimes = 4 + +// HistoryMax は発見ログ保持行数 +// Ref: perl/lib/Hako/Const.pm:276 +const HistoryMax = 10 + +// GiveupTurn は放棄コマンド自動入力ターン数 +// Ref: perl/lib/Hako/Const.pm:279 +const GiveupTurn = 28 + +// CommandMax はコマンド入力限界数 +// Ref: perl/lib/Hako/Const.pm:283 +const CommandMax = 20 + +// UseLbbs はローカル掲示板を使用するかどうか (0:使用しない、1:使用する) +// Ref: perl/lib/Hako/Const.pm:286 +const UseLbbs = 0 + +// LbbsMax はローカル掲示板行数 +// Ref: perl/lib/Hako/Const.pm:289 +const LbbsMax = 10 + +// IslandSize は島の大きさ +// Ref: perl/lib/Hako/Const.pm:293 +const IslandSize = 12 + +// HideMoneyMode は他人から資金を見えなくするか (0: 見えない, 1: 見える, 2: 100の位で四捨五入) +// Ref: perl/lib/Hako/Const.pm:299 +const HideMoneyMode = 2 + +// CryptOn はパスワードの暗号化 (false: 暗号化しない, true: 暗号化する) +// Ref: perl/lib/Hako/Const.pm:302 +const CryptOn = true + +// Debug はデバッグモード (trueだと、「ターンを進める」ボタンが使用できる) +// Ref: perl/lib/Hako/Const.pm:305 +const Debug = false + +//---------------------------------------------------------------------- +// 資金、食料などの設定値と単位 +//---------------------------------------------------------------------- + +// InitialMoney は初期資金 +// Ref: perl/lib/Hako/Const.pm:311 +const InitialMoney = 100 + +// InitialFood は初期食料 +// Ref: perl/lib/Hako/Const.pm:314 +const InitialFood = 100 + +// UnitMoney はお金の単位 +// Ref: perl/lib/Hako/Const.pm:317 +const UnitMoney = "億円" + +// UnitFood は食料の単位 +// Ref: perl/lib/Hako/Const.pm:320 +const UnitFood = "00トン" + +// UnitPop は人口の単位 +// Ref: perl/lib/Hako/Const.pm:323 +const UnitPop = "00人" + +// UnitArea は広さの単位 +// Ref: perl/lib/Hako/Const.pm:326 +const UnitArea = "00万坪" + +// UnitTree は木の数の単位 +// Ref: perl/lib/Hako/Const.pm:329 +const UnitTree = "00本" + +// TreeValue は木の単位当たりの売値 +// Ref: perl/lib/Hako/Const.pm:332 +const TreeValue = 5 + +// CostChangeName は名前変更のコスト +// Ref: perl/lib/Hako/Const.pm:335 +const CostChangeName = 500 + +// EatenFood は人口1単位あたりの食料消費料 +// Ref: perl/lib/Hako/Const.pm:338 +const EatenFood = 0.2 + +//---------------------------------------------------------------------- +// 基地の経験値 +//---------------------------------------------------------------------- + +// MaxExpPoint は経験値の最大値 (ただし、最大でも255まで) +// Ref: perl/lib/Hako/Const.pm:344 +const MaxExpPoint = 200 + +// MaxBaseLevel はミサイル基地のレベルの最大値 +// Ref: perl/lib/Hako/Const.pm:347 +const MaxBaseLevel = 5 + +// MaxSBaseLevel は海底基地のレベルの最大値 +// Ref: perl/lib/Hako/Const.pm:348 +const MaxSBaseLevel = 3 + +// BaseLevelUp はミサイル基地の経験値がいくつでレベルアップか +// Ref: perl/lib/Hako/Const.pm:352 +var BaseLevelUp = []int{20, 60, 120, 200} + +// SBaseLevelUp は海底基地の経験値がいくつでレベルアップか +// Ref: perl/lib/Hako/Const.pm:353 +var SBaseLevelUp = []int{50, 200} + +//---------------------------------------------------------------------- +// 防衛施設の自爆 +//---------------------------------------------------------------------- + +// DBaseAuto は怪獣に踏まれた時自爆するかどうか (true: 自爆する, false: 自爆しない) +// Ref: perl/lib/Hako/Const.pm:359 +const DBaseAuto = true + +//---------------------------------------------------------------------- +// 災害 +//---------------------------------------------------------------------- + +// 通常災害発生率 (確率は0.1%単位) +// Ref: perl/lib/Hako/Const.pm:365-372 + +// DisEarthquake は地震の発生率 +const DisEarthquake = 5 + +// DisTsunami は津波の発生率 +const DisTsunami = 15 + +// DisTyphoon は台風の発生率 +const DisTyphoon = 20 + +// DisMeteo は隕石の発生率 +const DisMeteo = 15 + +// DisHugeMeteo は巨大隕石の発生率 +const DisHugeMeteo = 5 + +// DisEruption は噴火の発生率 +const DisEruption = 10 + +// DisFire は火災の発生率 +const DisFire = 10 + +// DisMaizo は埋蔵金の発生率 +const DisMaizo = 10 + +// 地盤沈下 +// Ref: perl/lib/Hako/Const.pm:375-376 + +// DisFallBorder は地盤沈下の安全限界の広さ (Hex数) +const DisFallBorder = 90 + +// DisFalldown は安全限界を超えた場合の地盤沈下確率 +const DisFalldown = 30 + +// 怪獣 +// Ref: perl/lib/Hako/Const.pm:379-390 + +// DisMonsBorder1 は怪獣レベル1の人口基準 +const DisMonsBorder1 = 1000 + +// DisMonsBorder2 は怪獣レベル2の人口基準 +const DisMonsBorder2 = 2500 + +// DisMonsBorder3 は怪獣レベル3の人口基準 +const DisMonsBorder3 = 4000 + +// DisMonster は怪獣の単位面積あたりの出現率 (0.01%単位) +const DisMonster = 3 + +// MonsterNumber は怪獣の種類数 +const MonsterNumber = 8 + +// MonsterLevel1 はレベル1で出現する怪獣の番号の最大値 (サンジラまで) +const MonsterLevel1 = 2 + +// MonsterLevel2 はレベル2で出現する怪獣の番号の最大値 (いのらゴーストまで) +const MonsterLevel2 = 5 + +// MonsterLevel3 はレベル3で出現する怪獣の番号の最大値 (キングいのらまで=全部) +const MonsterLevel3 = 7 + +// MonsterName は怪獣の名前 +// Ref: perl/lib/Hako/Const.pm:393-402 +var MonsterName = []string{ + "メカいのら", // 0 (人造) + "いのら", // 1 + "サンジラ", // 2 + "レッドいのら", // 3 + "ダークいのら", // 4 + "いのらゴースト", // 5 + "クジラ", // 6 + "キングいのら", // 7 +} + +// MonsterBHP は怪獣の最低体力 +// Ref: perl/lib/Hako/Const.pm:405 +var MonsterBHP = []int{2, 1, 1, 3, 2, 1, 4, 5} + +// MonsterDHP は怪獣の体力の幅 +// Ref: perl/lib/Hako/Const.pm:406 +var MonsterDHP = []int{0, 2, 2, 2, 2, 0, 2, 2} + +// Ax は六角形グリッドのx方向オフセット配列 +// インデックス0: 中心, 1-6: 隣接6方向, 7-18: 2ヘックス先の12方向 +// Ref: perl/lib/Hako/Turn.pm:33 +var Ax = []int{0, 1, 1, 1, 0, -1, 0, 1, 2, 2, 2, 1, 0, -1, -1, -2, -1, -1, 0} + +// Ay は六角形グリッドのy方向オフセット配列 +// インデックス0: 中心, 1-6: 隣接6方向, 7-18: 2ヘックス先の12方向 +// Ref: perl/lib/Hako/Turn.pm:34 +var Ay = []int{0, -1, 0, 1, 1, 0, -1, -2, -1, 0, 1, 2, 2, 2, 1, 0, -1, -2, -2} + +// MonsterSpecial は怪獣の特殊能力 +// 0: 特になし, 1: 足が速い(最大2歩), 2: 足がとても速い(何歩か不明), +// 3: 奇数ターンは硬化, 4: 偶数ターンは硬化 +// Ref: perl/lib/Hako/Const.pm:407 +var MonsterSpecial = []int{0, 0, 3, 0, 1, 2, 4, 0} + +// MonsterExp は怪獣の経験値 +// Ref: perl/lib/Hako/Const.pm:408 +var MonsterExp = []int{5, 5, 7, 12, 15, 10, 20, 30} + +// MonsterValue は怪獣の死体の値段 +// Ref: perl/lib/Hako/Const.pm:409 +var MonsterValue = []int{0, 400, 500, 1000, 800, 300, 1500, 2000} + +// MonsterImage は怪獣の画像ファイル +// Ref: perl/lib/Hako/Const.pm:419-422 +var MonsterImage = []string{ + "monster7.gif", "monster0.gif", "monster5.gif", "monster1.gif", + "monster2.gif", "monster8.gif", "monster6.gif", "monster3.gif", +} + +// MonsterImage2 は怪獣の画像ファイルその2 (硬化中) +// Ref: perl/lib/Hako/Const.pm:425-426 +var MonsterImage2 = []string{ + "", "", "monster4.gif", "", "", "", "monster4.gif", "", +} + +//---------------------------------------------------------------------- +// 油田 +//---------------------------------------------------------------------- + +// OilMoney は油田の収入 +// Ref: perl/lib/Hako/Const.pm:432 +const OilMoney = 1000 + +// OilRatio は油田の枯渇確率 +// Ref: perl/lib/Hako/Const.pm:435 +const OilRatio = 40 + +//---------------------------------------------------------------------- +// 記念碑 +//---------------------------------------------------------------------- + +// MonumentNumber は記念碑の種類数 +// Ref: perl/lib/Hako/Const.pm:441 +const MonumentNumber = 3 + +// MonumentName は記念碑の名前 +// Ref: perl/lib/Hako/Const.pm:444 +var MonumentName = []string{"モノリス", "平和記念碑", "戦いの碑"} + +// MonumentImage は記念碑の画像ファイル +// Ref: perl/lib/Hako/Const.pm:447 +var MonumentImage = []string{"monument0.gif", "monument0.gif", "monument0.gif"} + +//---------------------------------------------------------------------- +// 賞関係 +//---------------------------------------------------------------------- + +// TurnPrizeUnit はターン杯を何ターン毎に出すか +// Ref: perl/lib/Hako/Const.pm:453 +const TurnPrizeUnit = 100 + +// Prize は賞の名前 +// Ref: perl/lib/Hako/Const.pm:456-462 +var Prize = []string{ + "ターン杯", "繁栄賞", + "超繁栄賞", "究極繁栄賞", + "平和賞", "超平和賞", + "究極平和賞", "災難賞", + "超災難賞", "究極災難賞", +} + +//---------------------------------------------------------------------- +// 外見関係 +//---------------------------------------------------------------------- + +// HTMLBody はタグのオプション +// Ref: perl/lib/Hako/Const.pm:468 +const HTMLBody = `BGCOLOR="#EEFFFF"` + +// Title はゲームのタイトル文字 +// Ref: perl/lib/Hako/Const.pm:471 +const Title = "箱庭諸島2" + +// タグ +// Ref: perl/lib/Hako/Const.pm:475-516 + +// TagTitleBegin はタイトル文字開始タグ +const TagTitleBegin = `` + +// TagTitleEnd はタイトル文字終了タグ +const TagTitleEnd = `` + +// TagHeaderBegin はH1タグ用開始タグ +const TagHeaderBegin = `` + +// TagHeaderEnd はH1タグ用終了タグ +const TagHeaderEnd = `` + +// TagBigBegin は大きい文字開始タグ +const TagBigBegin = `` + +// TagBigEnd は大きい文字終了タグ +const TagBigEnd = `` + +// TagNameBegin は島の名前など開始タグ +const TagNameBegin = `` + +// TagNameEnd は島の名前など終了タグ +const TagNameEnd = `` + +// TagName2Begin は薄くなった島の名前開始タグ +const TagName2Begin = `` + +// TagName2End は薄くなった島の名前終了タグ +const TagName2End = `` + +// TagNumberBegin は順位の番号など開始タグ +const TagNumberBegin = `` + +// TagNumberEnd は順位の番号など終了タグ +const TagNumberEnd = `` + +// TagTHBegin は順位表における見だし開始タグ +const TagTHBegin = `` + +// TagTHEnd は順位表における見だし終了タグ +const TagTHEnd = `` + +// TagComNameBegin は開発計画の名前開始タグ +const TagComNameBegin = `` + +// TagComNameEnd は開発計画の名前終了タグ +const TagComNameEnd = `` + +// TagDisasterBegin は災害開始タグ +const TagDisasterBegin = `` + +// TagDisasterEnd は災害終了タグ +const TagDisasterEnd = `` + +// TagLbbsSSBegin はローカル掲示板、観光者の書いた文字開始タグ +const TagLbbsSSBegin = `` + +// TagLbbsSSEnd はローカル掲示板、観光者の書いた文字終了タグ +const TagLbbsSSEnd = `` + +// TagLbbsOWBegin はローカル掲示板、島主の書いた文字開始タグ +const TagLbbsOWBegin = `` + +// TagLbbsOWEnd はローカル掲示板、島主の書いた文字終了タグ +const TagLbbsOWEnd = `` + +// NormalColor は通常の文字色 +// Ref: perl/lib/Hako/Const.pm:519 +const NormalColor = "#000000" + +// 順位表、セルの属性 +// Ref: perl/lib/Hako/Const.pm:522-529 + +// BgTitleCell は順位表見出しのセル属性 +const BgTitleCell = `BGCOLOR="#ccffcc"` + +// BgNumberCell は順位表順位のセル属性 +const BgNumberCell = `BGCOLOR="#ccffcc"` + +// BgNameCell は順位表島の名前のセル属性 +const BgNameCell = `BGCOLOR="#ccffff"` + +// BgInfoCell は順位表島の情報のセル属性 +const BgInfoCell = `BGCOLOR="#ccffff"` + +// BgCommentCell は順位表コメント欄のセル属性 +const BgCommentCell = `BGCOLOR="#ccffcc"` + +// BgInputCell は開発計画フォームのセル属性 +const BgInputCell = `BGCOLOR="#ccffcc"` + +// BgMapCell は開発計画地図のセル属性 +const BgMapCell = `BGCOLOR="#ccffcc"` + +// BgCommandCell は開発計画入力済み計画のセル属性 +const BgCommandCell = `BGCOLOR="#ccffcc"` + +//---------------------------------------------------------------------- +// これ以降のスクリプトは、変更されることを想定していません +//---------------------------------------------------------------------- + +// ThisFile はこのファイルのURL +// Ref: perl/lib/Hako/Const.pm:545 +var ThisFile = BaseDir + "/hako-main.cgi" + +//---------------------------------------------------------------------- +// 地形番号 +//---------------------------------------------------------------------- +// Ref: perl/lib/Hako/Const.pm:548-562 + +const ( + LandSea = 0 // 海 + LandWaste = 1 // 荒地 + LandPlains = 2 // 平地 + LandTown = 3 // 町系 + LandForest = 4 // 森 + LandFarm = 5 // 農場 + LandFactory = 6 // 工場 + LandBase = 7 // ミサイル基地 + LandDefence = 8 // 防衛施設 + LandMountain = 9 // 山 + LandMonster = 10 // 怪獣 + LandSbase = 11 // 海底基地 + LandOil = 12 // 海底油田 + LandMonument = 13 // 記念碑 + LandHaribote = 14 // ハリボテ +) + +//---------------------------------------------------------------------- +// コマンド +//---------------------------------------------------------------------- +// Ref: perl/lib/Hako/Const.pm:565-604 + +// CommandTotal はコマンドの種類数 +const CommandTotal = 28 + +// コマンド番号 +const ( + // 整地系 + ComPrepare = 1 // 整地 + ComPrepare2 = 2 // 地ならし + ComReclaim = 3 // 埋め立て + ComDestroy = 4 // 掘削 + ComSellTree = 5 // 伐採 + + // 作る系 + ComPlant = 11 // 植林 + ComFarm = 12 // 農場整備 + ComFactory = 13 // 工場建設 + ComMountain = 14 // 採掘場整備 + ComBase = 15 // ミサイル基地建設 + ComDbase = 16 // 防衛施設建設 + ComSbase = 17 // 海底基地建設 + ComMonument = 18 // 記念碑建造 + ComHaribote = 19 // ハリボテ設置 + + // 発射系 + ComMissileNM = 31 // ミサイル発射 + ComMissilePP = 32 // PPミサイル発射 + ComMissileST = 33 // STミサイル発射 + ComMissileLD = 34 // 陸地破壊弾発射 + ComSendMonster = 35 // 怪獣派遣 + + // 運営系 + ComDoNothing = 41 // 資金繰り + ComSell = 42 // 食料輸出 + ComMoney = 43 // 資金援助 + ComFood = 44 // 食料援助 + ComPropaganda = 45 // 誘致活動 + ComGiveup = 46 // 島の放棄 + + // 自動入力系 + ComAutoPrepare = 61 // フル整地 + ComAutoPrepare2 = 62 // フル地ならし + ComAutoDelete = 63 // 全コマンド消去 +) + +// ComList はコマンドの順番 +// Ref: perl/lib/Hako/Const.pm:607-618 +var ComList = []int{ + ComPrepare, ComSell, ComPrepare2, + ComReclaim, ComDestroy, ComSellTree, + ComPlant, ComFarm, ComFactory, + ComMountain, ComBase, ComDbase, + ComSbase, ComMonument, ComHaribote, + ComMissileNM, ComMissilePP, ComMissileST, + ComMissileLD, ComSendMonster, ComDoNothing, + ComMoney, ComFood, ComPropaganda, + ComGiveup, ComAutoPrepare, ComAutoPrepare2, + ComAutoDelete, +} + +// ComName はコマンドの名前 +// Ref: perl/lib/Hako/Const.pm:623-677 +var ComName = map[int]string{ + ComPrepare: "整地", + ComPrepare2: "地ならし", + ComReclaim: "埋め立て", + ComDestroy: "掘削", + ComSellTree: "伐採", + ComPlant: "植林", + ComFarm: "農場整備", + ComFactory: "工場建設", + ComMountain: "採掘場整備", + ComBase: "ミサイル基地建設", + ComDbase: "防衛施設建設", + ComSbase: "海底基地建設", + ComMonument: "記念碑建造", + ComHaribote: "ハリボテ設置", + ComMissileNM: "ミサイル発射", + ComMissilePP: "PPミサイル発射", + ComMissileST: "STミサイル発射", + ComMissileLD: "陸地破壊弾発射", + ComSendMonster: "怪獣派遣", + ComDoNothing: "資金繰り", + ComSell: "食料輸出", + ComMoney: "資金援助", + ComFood: "食料援助", + ComPropaganda: "誘致活動", + ComGiveup: "島の放棄", + ComAutoPrepare: "整地自動入力", + ComAutoPrepare2: "地ならし自動入力", + ComAutoDelete: "全計画を白紙撤回", +} + +// ComCost はコマンドの値段 +// Ref: perl/lib/Hako/Const.pm:623-678 +var ComCost = map[int]int{ + ComPrepare: 5, + ComPrepare2: 100, + ComReclaim: 150, + ComDestroy: 200, + ComSellTree: 0, + ComPlant: 50, + ComFarm: 20, + ComFactory: 100, + ComMountain: 300, + ComBase: 300, + ComDbase: 800, + ComSbase: 8000, + ComMonument: 9999, + ComHaribote: 1, + ComMissileNM: 20, + ComMissilePP: 50, + ComMissileST: 50, + ComMissileLD: 100, + ComSendMonster: 3000, + ComDoNothing: 0, + ComSell: -100, + ComMoney: 100, + ComFood: -100, + ComPropaganda: 1000, + ComGiveup: 0, + ComAutoPrepare: 0, + ComAutoPrepare2: 0, + ComAutoDelete: 0, +} + +// PointNumber は島の座標数 +// Ref: perl/lib/Hako/Const.pm:681 +const PointNumber = IslandSize * IslandSize + +// TempBack は「戻る」リンク +// Ref: perl/lib/Hako/Const.pm:684-685 +var TempBack = `` + TagBigBegin + `トップへ戻る` + TagBigEnd + `` diff --git a/go/internal/hako/maintenance/maintenance.go b/go/internal/hako/maintenance/maintenance.go new file mode 100644 index 0000000..cbff3e7 --- /dev/null +++ b/go/internal/hako/maintenance/maintenance.go @@ -0,0 +1,396 @@ +// Package maintenance provides maintenance tool functions for the Hakoniwa game. +// This file is translated from Perl lib/Hako/Maintenance.pm +// +// Ref: perl/lib/Hako/Maintenance.pm +package maintenance + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/neguse/hakoniwa/internal/hako/hconst" + "github.com/neguse/hakoniwa/internal/hako/variable" +) + +// Maintenance variables +var ( + mainMode string + inputPass string + deleteID string + currentID string + ctYear int + ctMon int + ctDate int + ctHour int + ctMin int + ctSec int +) + +// out outputs a string to the output buffer +func out(s string) { + variable.OutputBuffer.WriteString(s) +} + +// RunMaintenance runs the maintenance tool +// Ref: perl/lib/Hako/Maintenance.pm:70 +func RunMaintenance(r *http.Request) { + out(`Content-type: text/html + + + + +箱島2 メンテナンスツール + + +`) + + cgiInput(r) + + if mainMode == "delete" { + if passCheck() { + deleteMode() + } + } else if mainMode == "current" { + if passCheck() { + currentMode() + } + } else if mainMode == "time" { + if passCheck() { + timeMode() + } + } else if mainMode == "stime" { + if passCheck() { + stimeMode() + } + } else if mainMode == "new" { + if passCheck() { + newMode() + } + } + + mainModeDisplay() + + out(` + + +`) +} + +// myrmtree removes a directory and all its contents +// Ref: perl/lib/Hako/Maintenance.pm:120 +func myrmtree(dirName string) error { + dir, err := os.Open(dirName) + if err != nil { + return err + } + defer dir.Close() + + files, err := dir.Readdir(-1) + if err != nil { + return err + } + + for _, file := range files { + os.Remove(filepath.Join(dirName, file.Name())) + } + + return os.Remove(dirName) +} + +// currentMode restores a backup to current +// Ref: perl/lib/Hako/Maintenance.pm:131 +func currentMode() { + myrmtree(hconst.DirName) + os.Mkdir(hconst.DirName, os.FileMode(hconst.DirMode)) + + backupDir := hconst.DirName + ".bak" + currentID + dir, err := os.Open(backupDir) + if err != nil { + return + } + defer dir.Close() + + files, err := dir.Readdir(-1) + if err != nil { + return + } + + for _, file := range files { + if file.Name() != "." && file.Name() != ".." { + fileCopy( + filepath.Join(backupDir, file.Name()), + filepath.Join(hconst.DirName, file.Name()), + ) + } + } +} + +// deleteMode deletes current data or backup +// Ref: perl/lib/Hako/Maintenance.pm:143 +func deleteMode() { + if deleteID == "" { + myrmtree(hconst.DirName) + } else { + myrmtree(hconst.DirName + ".bak" + deleteID) + } + os.Remove("hakojimalockflock") +} + +// newMode creates new data directory +// Ref: perl/lib/Hako/Maintenance.pm:153 +func newMode() { + os.Mkdir(hconst.DirName, os.FileMode(hconst.DirMode)) + + // Get current time aligned to unit time + now := time.Now().Unix() + now = now - (now % int64(hconst.UnitTime)) + + // Create hakojima.dat + filename := filepath.Join(hconst.DirName, "hakojima.dat") + file, err := os.Create(filename) + if err != nil { + return + } + defer file.Close() + + fmt.Fprintf(file, "1\n") // Turn 1 + fmt.Fprintf(file, "%d\n", now) // Start time + fmt.Fprintf(file, "0\n") // Island count + fmt.Fprintf(file, "1\n") // Next ID +} + +// timeMode changes time using date/time fields +// Ref: perl/lib/Hako/Maintenance.pm:170 +func timeMode() { + ctMon-- + ctYear -= 1900 + + // Convert to Unix timestamp + t := time.Date(ctYear+1900, time.Month(ctMon+1), ctDate, ctHour, ctMin, ctSec, 0, time.Local) + ctSec = int(t.Unix()) + + stimeMode() +} + +// stimeMode changes time using seconds +// Ref: perl/lib/Hako/Maintenance.pm:177 +func stimeMode() { + filename := filepath.Join(hconst.DirName, "hakojima.dat") + file, err := os.Open(filename) + if err != nil { + return + } + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + file.Close() + + if len(lines) < 2 { + return + } + + // Update line 1 (0-indexed) with new time + lines[1] = fmt.Sprintf("%d", ctSec) + + // Write back + file, err = os.Create(filename) + if err != nil { + return + } + defer file.Close() + + for _, line := range lines { + fmt.Fprintf(file, "%s\n", line) + } +} + +// mainModeDisplay displays the main maintenance interface +// Ref: perl/lib/Hako/Maintenance.pm:191 +func mainModeDisplay() { + // Note: In Go version, this uses variable from hconst, not local thisFile + // For Phase 1, we'll use a simplified version + thisFile := "/hako-mente" + + out(fmt.Sprintf(`
+

箱島2 メンテナンスツール

+パスワード: +`, thisFile)) + + // Current data + if _, err := os.Stat(hconst.DirName); err == nil { + dataPrint("") + } else { + out(`
+ +`) + } + + // Backup data + files, err := os.ReadDir("./") + if err == nil { + for _, file := range files { + name := file.Name() + if strings.HasPrefix(name, hconst.DirName+".bak") { + suffix := strings.TrimPrefix(name, hconst.DirName+".bak") + dataPrint(suffix) + } + } + } +} + +// dataPrint displays data information +// Ref: perl/lib/Hako/Maintenance.pm:222 +func dataPrint(suf string) { + out("
") + + var filename string + if suf == "" { + filename = filepath.Join(hconst.DirName, "hakojima.dat") + out("

現役データ

") + } else { + filename = filepath.Join(hconst.DirName+".bak"+suf, "hakojima.dat") + out(fmt.Sprintf("

バックアップ%s

", suf)) + } + + file, err := os.Open(filename) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + var lastTurn string + if scanner.Scan() { + lastTurn = scanner.Text() + } + + var lastTime int64 + if scanner.Scan() { + lastTime, _ = strconv.ParseInt(scanner.Text(), 10, 64) + } + + timeString := timeToString(lastTime) + + out(fmt.Sprintf(` ターン%s
+ 最終更新時間:%s
+ 最終更新時間(秒数表示):1970年1月1日から%d 秒
+ +`, lastTurn, timeString, lastTime, suf)) + + if suf == "" { + t := time.Unix(lastTime, 0) + year := t.Year() + mon := int(t.Month()) + date := t.Day() + hour := t.Hour() + min := t.Minute() + sec := t.Second() + + out(fmt.Sprintf(`

最終更新時間の変更

+ 年 + 月 + 日 + 時 + 分 + 秒 +
+ 1970年1月1日から秒 + + +`, year, mon, date, hour, min, sec, lastTime)) + } else { + out(fmt.Sprintf(` +`, suf)) + } +} + +// timeToString formats a Unix timestamp +// Ref: perl/lib/Hako/Maintenance.pm:279 +func timeToString(timestamp int64) string { + t := time.Unix(timestamp, 0) + return fmt.Sprintf("%d年 %d月 %d日 %d時 %d分 %d秒", + t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) +} + +// cgiInput parses CGI input +// Ref: perl/lib/Hako/Maintenance.pm:289 +func cgiInput(r *http.Request) { + if err := r.ParseForm(); err != nil { + return + } + + mainMode = "" + + // Check for DELETE button + for key := range r.Form { + if strings.HasPrefix(key, "DELETE") { + mainMode = "delete" + deleteID = strings.TrimPrefix(key, "DELETE") + break + } + if strings.HasPrefix(key, "CURRENT") { + mainMode = "current" + currentID = strings.TrimPrefix(key, "CURRENT") + break + } + } + + if r.FormValue("NEW") != "" { + mainMode = "new" + } else if r.FormValue("NTIME") != "" { + mainMode = "time" + ctYear, _ = strconv.Atoi(r.FormValue("YEAR")) + ctMon, _ = strconv.Atoi(r.FormValue("MON")) + ctDate, _ = strconv.Atoi(r.FormValue("DATE")) + ctHour, _ = strconv.Atoi(r.FormValue("HOUR")) + ctMin, _ = strconv.Atoi(r.FormValue("MIN")) + ctSec, _ = strconv.Atoi(r.FormValue("NSEC")) + } else if r.FormValue("STIME") != "" { + mainMode = "stime" + ctSec, _ = strconv.Atoi(r.FormValue("SSEC")) + } + + inputPass = r.FormValue("PASSWORD") +} + +// fileCopy copies a file +// Ref: perl/lib/Hako/Maintenance.pm:345 +func fileCopy(src, dist string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + distFile, err := os.Create(dist) + if err != nil { + return err + } + defer distFile.Close() + + _, err = io.Copy(distFile, srcFile) + return err +} + +// passCheck checks master password +// Ref: perl/lib/Hako/Maintenance.pm:357 +func passCheck() bool { + if inputPass == hconst.MasterPassword { + return true + } + + out(` パスワードが違います。 +`) + return false +} diff --git a/go/internal/hako/mapview/mapview.go b/go/internal/hako/mapview/mapview.go new file mode 100644 index 0000000..34d21de --- /dev/null +++ b/go/internal/hako/mapview/mapview.go @@ -0,0 +1,1058 @@ +// Package mapview provides map viewing and island management functions for the Hakoniwa game. +// This file is translated from Perl lib/Hako/Map.pm +// +// Ref: perl/lib/Hako/Map.pm +package mapview + +import ( + "fmt" + + "github.com/neguse/hakoniwa/internal/hako/core" + "github.com/neguse/hakoniwa/internal/hako/hconst" + "github.com/neguse/hakoniwa/internal/hako/types" + "github.com/neguse/hakoniwa/internal/hako/variable" +) + +// out outputs a string to the output buffer +func out(s string) { + variable.OutputBuffer.WriteString(s) +} + +// PrintIslandMain displays the island viewing page +// Ref: perl/lib/Hako/Map.pm:38 +func PrintIslandMain() { + // Release lock + core.Unlock() + + // Get island number from ID + num, ok := variable.IDToNumber[variable.CurrentID] + if !ok { + core.TempProblem() + return + } + variable.CurrentNumber = num + + // Get island name + variable.CurrentName = variable.Islands[variable.CurrentNumber].Name + + // Display tourist view + tempPrintIslandHead() + islandInfo() + islandMap(0) + + // Local BBS + if hconst.UseLbbs != 0 { + tempLbbsHead() + tempLbbsInput() + tempLbbsContents() + } + + // Recent events + tempRecent(0) +} + +// OwnerMain displays the island development page +// Ref: perl/lib/Hako/Map.pm:75 +func OwnerMain() { + // Release lock + core.Unlock() + + // Set mode + variable.MainMode = "owner" + + // Get island from ID + num, ok := variable.IDToNumber[variable.CurrentID] + if !ok { + core.TempProblem() + return + } + variable.CurrentNumber = num + island := variable.Islands[variable.CurrentNumber] + variable.CurrentName = island.Name + + // Check password + if !core.CheckPassword(island.Password, variable.InputPassword) { + core.TempWrongPassword() + return + } + + // Development view + tempOwner() + + // Local BBS + if hconst.UseLbbs != 0 { + tempLbbsHead() + tempLbbsInputOW() + tempLbbsContents() + } + + // Recent events + tempRecent(1) +} + +// CommandMain processes command input +// Ref: perl/lib/Hako/Map.pm:114 +func CommandMain() { + // Get island from ID + num, ok := variable.IDToNumber[variable.CurrentID] + if !ok { + core.Unlock() + core.TempProblem() + return + } + variable.CurrentNumber = num + island := variable.Islands[variable.CurrentNumber] + variable.CurrentName = island.Name + + // Check password + if !core.CheckPassword(island.Password, variable.InputPassword) { + core.Unlock() + core.TempWrongPassword() + return + } + + // Branch by mode + commands := island.Commands + + if variable.CommandMode == "delete" { + // Delete command + slideFront(commands, variable.CommandPlanNumber) + tempCommandDelete() + } else if variable.CommandKind == hconst.ComAutoPrepare || variable.CommandKind == hconst.ComAutoPrepare2 { + // フル整地、フル地ならし + // 座標配列を作る + core.MakeRandomPointArray() + land := island.Land + + // コマンドの種類決定 + kind := hconst.ComPrepare + if variable.CommandKind == hconst.ComAutoPrepare2 { + kind = hconst.ComPrepare2 + } + + i := 0 + j := 0 + for j < hconst.IslandSize*hconst.IslandSize && i < hconst.CommandMax { + x := variable.Rpx[j] + y := variable.Rpy[j] + if land[x][y] == hconst.LandWaste { + slideBack(commands, variable.CommandPlanNumber) + commands[variable.CommandPlanNumber] = types.Command{ + Kind: kind, + Target: "", + X: x, + Y: y, + Arg: 0, + } + i++ + } + j++ + } + tempCommandAdd() + } else if variable.CommandKind == hconst.ComAutoDelete { + // 全消し + for i := 0; i < hconst.CommandMax; i++ { + slideFront(commands, variable.CommandPlanNumber) + } + tempCommandDelete() + } else { + // Normal command + if variable.CommandMode == "insert" { + slideBack(commands, variable.CommandPlanNumber) + } + tempCommandAdd() + + // Register command + if variable.CommandPlanNumber >= 0 && variable.CommandPlanNumber < len(commands) { + commands[variable.CommandPlanNumber] = types.Command{ + Kind: variable.CommandKind, + Target: variable.CommandTarget, + X: variable.CommandX, + Y: variable.CommandY, + Arg: variable.CommandArg, + } + } + } + + // Write data + core.WriteIslandsFile() + + // Go to owner mode + OwnerMain() +} + +// CommentMain processes comment input +// Ref: perl/lib/Hako/Map.pm:208 +func CommentMain() { + // Get island from ID + num, ok := variable.IDToNumber[variable.CurrentID] + if !ok { + core.Unlock() + core.TempProblem() + return + } + variable.CurrentNumber = num + island := variable.Islands[variable.CurrentNumber] + variable.CurrentName = island.Name + + // Check password + if !core.CheckPassword(island.Password, variable.InputPassword) { + core.Unlock() + core.TempWrongPassword() + return + } + + // Update comment + island.Comment = core.HtmlEscape(variable.Message) + + // Write data + core.WriteIslandsFile() + + // Comment update message + tempComment() + + // Go to owner mode + OwnerMain() +} + +// LocalBbsMain processes local BBS operations +// Ref: perl/lib/Hako/Map.pm:242 +func LocalBbsMain() { + // Get island number from ID + num, ok := variable.IDToNumber[variable.CurrentID] + if !ok { + core.Unlock() + core.TempProblem() + return + } + variable.CurrentNumber = num + island := variable.Islands[variable.CurrentNumber] + + // Check for missing name/message (except delete mode) + if variable.LbbsMode != 2 { + if variable.LbbsName == "" || variable.LbbsMessage == "" { + core.Unlock() + tempLbbsNoMessage() + return + } + } + + // Check password for non-tourist mode + if variable.LbbsMode != 0 { + if !core.CheckPassword(island.Password, variable.InputPassword) { + core.Unlock() + core.TempWrongPassword() + return + } + } + + lbbs := island.Lbbs + + // Mode handling + if variable.LbbsMode == 2 { + // Delete mode + slideBackLbbsMessage(&lbbs, variable.CommandPlanNumber) + tempLbbsDelete() + } else { + // Add mode + slideLbbsMessage(&lbbs) + + // Write message + var modeFlag string + if variable.LbbsMode == 0 { + modeFlag = "0" + } else { + modeFlag = "1" + } + + variable.LbbsName = fmt.Sprintf("%d:%s", variable.IslandTurn, core.HtmlEscape(variable.LbbsName)) + variable.LbbsMessage = core.HtmlEscape(variable.LbbsMessage) + if len(lbbs) > 0 { + lbbs[0] = types.LbbsEntry{ + Message: fmt.Sprintf("%s>%s>%s", modeFlag, variable.LbbsName, variable.LbbsMessage), + } + } + + tempLbbsAdd() + } + + // Update island lbbs + island.Lbbs = lbbs + + // Write data + core.WriteIslandsFile() + + // Return to original mode + if variable.LbbsMode == 0 { + PrintIslandMain() + } else { + OwnerMain() + } +} + +// Helper functions for command/lbbs array manipulation + +func slideFront(commands []types.Command, number int) { + if number >= 0 && number < len(commands)-1 { + copy(commands[number:], commands[number+1:]) + commands[len(commands)-1] = types.Command{Kind: 0} + } +} + +func slideBack(commands []types.Command, number int) { + if number >= 0 && number < len(commands)-1 { + copy(commands[number+1:], commands[number:]) + } +} + +func slideLbbsMessage(lbbs *[]types.LbbsEntry) { + if len(*lbbs) > 0 { + // Remove last, insert at beginning + *lbbs = (*lbbs)[:len(*lbbs)-1] + *lbbs = append([]types.LbbsEntry{{Message: ""}}, *lbbs...) + } +} + +func slideBackLbbsMessage(lbbs *[]types.LbbsEntry, number int) { + if number >= 0 && number < len(*lbbs) { + // Remove element at number + *lbbs = append((*lbbs)[:number], (*lbbs)[number+1:]...) + // Add empty at end + *lbbs = append(*lbbs, types.LbbsEntry{Message: "0>>"}) + } +} + +// islandInfo displays island information table +// Ref: perl/lib/Hako/Map.pm:342 +func islandInfo() { + island := variable.Islands[variable.CurrentNumber] + + // Display info + rank := variable.CurrentNumber + 1 + farm := island.Farm + factory := island.Factory + mountain := island.Mountain + + farmStr := "保有せず" + if farm != 0 { + farmStr = fmt.Sprintf("%d0%s", farm, hconst.UnitPop) + } + factoryStr := "保有せず" + if factory != 0 { + factoryStr = fmt.Sprintf("%d0%s", factory, hconst.UnitPop) + } + mountainStr := "保有せず" + if mountain != 0 { + mountainStr = fmt.Sprintf("%d0%s", mountain, hconst.UnitPop) + } + + mStr1 := "" + mStr2 := "" + if hconst.HideMoneyMode == 1 || variable.MainMode == "owner" { + mStr1 = fmt.Sprintf("%s資金%s", + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd) + mStr2 = fmt.Sprintf("%d%s", + hconst.BgInfoCell, island.Money, hconst.UnitMoney) + } else if hconst.HideMoneyMode == 2 { + mTmp := core.AboutMoney(island.Money) + mStr1 = fmt.Sprintf("%s資金%s", + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd) + mStr2 = fmt.Sprintf("%s", + hconst.BgInfoCell, mTmp) + } + + out(fmt.Sprintf(`
+ + + + +%s + + + + + + + + + +%s + + + + + + +
%s順位%s%s人口%s%s食料%s%s面積%s%s農場規模%s%s工場規模%s%s採掘場規模%s
%s%d%s%d%s%d%s%d%s%s%s%s
+`, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + mStr1, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgNumberCell, hconst.TagNumberBegin, rank, hconst.TagNumberEnd, + hconst.BgInfoCell, island.Pop, hconst.UnitPop, + mStr2, + hconst.BgInfoCell, island.Food, hconst.UnitFood, + hconst.BgInfoCell, island.Area, hconst.UnitArea, + hconst.BgInfoCell, farmStr, + hconst.BgInfoCell, factoryStr, + hconst.BgInfoCell, mountainStr, + )) +} + +// islandMap displays the island map +// mode: 0=tourist view, 1=owner view +// Ref: perl/lib/Hako/Map.pm:402 +func islandMap(mode int) { + island := variable.Islands[variable.CurrentNumber] + + out("
\n") + + // Get terrain data + land := island.Land + landValue := island.LandValue + + // Get commands for owner mode + var comStr [][]string + if variable.MainMode == "owner" { + comStr = make([][]string, hconst.IslandSize) + for i := 0; i < hconst.IslandSize; i++ { + comStr[i] = make([]string, hconst.IslandSize) + } + + for i := 0; i < len(island.Commands) && i < hconst.CommandMax; i++ { + j := i + 1 + com := island.Commands[i] + if com.Kind < 20 { + comStr[com.X][com.Y] += fmt.Sprintf(" [%d]%s", j, hconst.ComName[com.Kind]) + } + } + } + + // Output coordinate bar (top) + out("
") + + // Output terrain for each cell + for y := 0; y < hconst.IslandSize; y++ { + // 偶数行目なら番号を出力 + if (y % 2) == 0 { + out(fmt.Sprintf("", y)) + } + + for x := 0; x < hconst.IslandSize; x++ { + l := land[x][y] + lv := landValue[x][y] + var comStrXY string + if comStr != nil { + comStrXY = comStr[x][y] + } + landString(l, lv, x, y, mode, comStrXY) + } + + // 奇数行目なら番号を出力 + if (y % 2) == 1 { + out(fmt.Sprintf("", y)) + } + + out("
\n") + } + + out("
\n") +} + +// landString outputs HTML for a single terrain cell +// Ref: perl/lib/Hako/Map.pm:460 +func landString(l, lv, x, y, mode int, comStr string) { + point := fmt.Sprintf("(%d,%d)", x, y) + var image, alt string + + switch l { + case hconst.LandSea: + if lv == 1 { + image = "land14.gif" + alt = "海(浅瀬)" + } else { + image = "land0.gif" + alt = "海" + } + + case hconst.LandWaste: + if lv == 1 { + image = "land13.gif" + alt = "荒地" + } else { + image = "land1.gif" + alt = "荒地" + } + + case hconst.LandPlains: + image = "land2.gif" + alt = "平地" + + case hconst.LandForest: + if mode == 1 { + image = "land6.gif" + alt = fmt.Sprintf("森(%d%s)", lv, hconst.UnitTree) + } else { + image = "land6.gif" + alt = "森" + } + + case hconst.LandTown: + var p int + var n string + if lv < 30 { + p = 3 + n = "村" + } else if lv < 100 { + p = 4 + n = "町" + } else { + p = 5 + n = "都市" + } + image = fmt.Sprintf("land%d.gif", p) + alt = fmt.Sprintf("%s(%d%s)", n, lv, hconst.UnitPop) + + case hconst.LandFarm: + image = "land7.gif" + alt = fmt.Sprintf("農場(%d0%s規模)", lv, hconst.UnitPop) + + case hconst.LandFactory: + image = "land8.gif" + alt = fmt.Sprintf("工場(%d0%s規模)", lv, hconst.UnitPop) + + case hconst.LandBase: + if mode == 0 { + image = "land6.gif" + alt = "森" + } else { + level := core.ExpToLevel(l, lv) + image = "land9.gif" + alt = fmt.Sprintf("ミサイル基地 (レベル %d/経験値 %d)", level, lv) + } + + case hconst.LandSbase: + if mode == 0 { + image = "land0.gif" + alt = "海" + } else { + level := core.ExpToLevel(l, lv) + image = "land12.gif" + alt = fmt.Sprintf("海底基地 (レベル %d/経験値 %d)", level, lv) + } + + case hconst.LandDefence: + image = "land10.gif" + alt = "防衛施設" + + case hconst.LandHaribote: + image = "land10.gif" + if mode == 0 { + alt = "防衛施設" + } else { + alt = "ハリボテ" + } + + case hconst.LandOil: + image = "land16.gif" + alt = "海底油田" + + case hconst.LandMountain: + if lv > 0 { + image = "land15.gif" + alt = fmt.Sprintf("山(採掘場%d0%s規模)", lv, hconst.UnitPop) + } else { + image = "land11.gif" + alt = "山" + } + + case hconst.LandMonument: + image = hconst.MonumentImage[lv] + alt = hconst.MonumentName[lv] + + case hconst.LandMonster: + kind, name, hp := core.MonsterSpec(lv) + special := hconst.MonsterSpecial[kind] + image = hconst.MonsterImage[kind] + + // Hardened? + if (special == 3 && (variable.IslandTurn%2) == 1) || + (special == 4 && (variable.IslandTurn%2) == 0) { + image = hconst.MonsterImage2[kind] + } + alt = fmt.Sprintf("怪獣%s(体力%d)", name, hp) + + default: + image = "land1.gif" + alt = "不明" + } + + // For development view, add click handler for coordinates + if mode == 1 { + out(fmt.Sprintf("", x, y)) + } + + out(fmt.Sprintf("\"%s", image, point, alt, comStr)) + + if mode == 1 { + out("") + } +} + +// Template functions + +func tempPrintIslandHead() { + out(fmt.Sprintf(`
+%s%s「%s島」%sへようこそ!!%s
+%s
+
+`, + hconst.TagBigBegin, hconst.TagNameBegin, variable.CurrentName, hconst.TagNameEnd, hconst.TagBigEnd, + hconst.TempBack, + )) +} + +func tempOwner() { + island := variable.Islands[variable.CurrentNumber] + + out(fmt.Sprintf(`
+%s%s%s島%s開発計画%s
+%s
+
+ +`, + hconst.TagBigBegin, hconst.TagNameBegin, variable.CurrentName, hconst.TagNameEnd, hconst.TagBigEnd, + hconst.TempBack, + )) + + islandInfo() + + out(fmt.Sprintf(`
+ + + + + + +
+
+ + +
+パスワード
+ +
+計画番号
+
+開発計画
+ +
+座標( +, ) +
+数量 +
+目標の島
+ +
+動作
+挿入 +上書き
+削除 +
+ + +
+ +
+`, + variable.TargetList, + island.ID, + hconst.BgMapCell, + )) + + islandMap(1) // 島の地図、所有者モード + + out(fmt.Sprintf(` +`, hconst.BgCommandCell)) + + // 入力済みコマンド表示 + for i := 0; i < hconst.CommandMax; i++ { + tempCommand(i, island.Commands[i]) + } + + out(` +
+
+
+
+`) + + out(fmt.Sprintf(`%sコメント更新%s
+
+コメント
+パスワード + +
+
+`, + hconst.TagBigBegin, hconst.TagBigEnd, + hconst.ThisFile, + variable.DefaultPassword, + island.ID, + )) +} + +// tempCommand displays a single command in the command list +// Ref: perl/lib/Hako/Map.pm:837 +func tempCommand(number int, command types.Command) { + kind := command.Kind + target := command.Target + x := command.X + y := command.Y + arg := command.Arg + + name := fmt.Sprintf("%s%s%s", hconst.TagComNameBegin, hconst.ComName[kind], hconst.TagComNameEnd) + point := fmt.Sprintf("%s(%d,%d)%s", hconst.TagNameBegin, x, y, hconst.TagNameEnd) + + targetName := variable.IDToName[target] + if targetName != "" { + targetName = fmt.Sprintf("%s%s島%s", hconst.TagNameBegin, targetName, hconst.TagNameEnd) + } else if target != "" { + targetName = fmt.Sprintf("%s無人%s", hconst.TagNameBegin, hconst.TagNameEnd) + } + + value := arg * hconst.ComCost[kind] + if value == 0 { + value = hconst.ComCost[kind] + } + + valueStr := "" + if value < 0 { + valueStr = fmt.Sprintf("%d%s", -value, hconst.UnitFood) + } else { + valueStr = fmt.Sprintf("%d%s", value, hconst.UnitMoney) + } + valueStr = fmt.Sprintf("%s%s%s", hconst.TagNameBegin, valueStr, hconst.TagNameEnd) + + j := fmt.Sprintf("%02d:", number+1) + + out(fmt.Sprintf(`%s%s%s`, + number, hconst.TagNumberBegin, j, hconst.TagNumberEnd, hconst.NormalColor)) + + if kind == hconst.ComDoNothing || kind == hconst.ComGiveup { + out(name) + } else if kind == hconst.ComMissileNM || kind == hconst.ComMissilePP || + kind == hconst.ComMissileST || kind == hconst.ComMissileLD { + // ミサイル系 + n := "無制限" + if arg != 0 { + n = fmt.Sprintf("%d発", arg) + } + out(fmt.Sprintf("%s%sへ%s(%s%s%s)", targetName, point, name, hconst.TagNameBegin, n, hconst.TagNameEnd)) + } else if kind == hconst.ComSendMonster { + // 怪獣派遣 + out(fmt.Sprintf("%sへ%s", targetName, name)) + } else if kind == hconst.ComSell { + // 食料輸出 + out(fmt.Sprintf("%s%s", name, valueStr)) + } else if kind == hconst.ComPropaganda { + // 誘致活動 + out(name) + } else if kind == hconst.ComMoney || kind == hconst.ComFood { + // 援助 + out(fmt.Sprintf("%sへ%s%s", targetName, name, valueStr)) + } else if kind == hconst.ComDestroy { + // 掘削 + if arg != 0 { + out(fmt.Sprintf("%sで%s(予算%s)", point, name, valueStr)) + } else { + out(fmt.Sprintf("%sで%s", point, name)) + } + } else if kind == hconst.ComFarm || kind == hconst.ComFactory || kind == hconst.ComMountain { + // 回数付き + if arg == 0 { + out(fmt.Sprintf("%sで%s", point, name)) + } else { + out(fmt.Sprintf("%sで%s(%d回)", point, name, arg)) + } + } else { + // 座標付き + out(fmt.Sprintf("%sで%s", point, name)) + } + + out("
") +} + +func tempRecent(mode int) { + out(fmt.Sprintf("

%s近況%s

\n", hconst.TagHeaderBegin, hconst.TagHeaderEnd)) + logPrintLocal(mode) +} + +func logPrintLocal(mode int) { + // Phase 1: Stub - would call logFilePrint for each log file + out("

(近況ログ表示は未実装)

\n") +} + +func tempLbbsHead() { + out(fmt.Sprintf(`
+
+%s%s%s島%s観光者通信%s
+
+`, + hconst.TagBigBegin, hconst.TagNameBegin, variable.CurrentName, hconst.TagNameEnd, hconst.TagBigEnd)) +} + +func tempLbbsInput() { + out(fmt.Sprintf(`
+
+ + + + + + + + + + + +
名前内容動作
+
+
+`, + hconst.ThisFile, + variable.DefaultName, + variable.CurrentID, + )) +} + +func tempLbbsInputOW() { + out(fmt.Sprintf(`
+
+ + + + + + + + + + + + + + + + + + +
名前内容
パスワード動作
+ + +番号 + + +
+
+
+`, variable.CurrentID)) +} + +func tempLbbsContents() { + lbbs := variable.Islands[variable.CurrentNumber].Lbbs + + out(`
+ + + + + +`) + + for i := 0; i < hconst.LbbsMax && i < len(lbbs); i++ { + line := lbbs[i].Message + // Parse format: "mode>name>message" + // mode: 0=tourist, 1=owner + var mode, name, message string + if len(line) > 0 { + // Simple parsing + parts := splitN(line, ">", 3) + if len(parts) >= 3 { + mode = parts[0] + name = parts[1] + message = parts[2] + + j := i + 1 + out(fmt.Sprintf("", hconst.TagNumberBegin, j, hconst.TagNumberEnd)) + + if mode == "0" { + // 観光者 + out(fmt.Sprintf("", hconst.TagLbbsSSBegin, name, message, hconst.TagLbbsSSEnd)) + } else { + // 島主 + out(fmt.Sprintf("", hconst.TagLbbsOWBegin, name, message, hconst.TagLbbsOWEnd)) + } + } + } + } + + out(`
番号記帳内容
%s%d%s%s%s > %s%s
%s%s > %s%s
+`) +} + +// splitN splits a string by separator, limiting to n parts +func splitN(s, sep string, n int) []string { + result := []string{} + for i := 0; i < n-1; i++ { + idx := -1 + for j := 0; j < len(s); j++ { + if s[j:j+len(sep)] == sep { + idx = j + break + } + } + if idx == -1 { + result = append(result, s) + return result + } + result = append(result, s[:idx]) + s = s[idx+len(sep):] + } + result = append(result, s) + return result +} + +func tempLbbsNoMessage() { + out(fmt.Sprintf("%s名前または内容の欄が空欄です。%s%s", + hconst.TagBigBegin, hconst.TagBigEnd, hconst.TempBack)) +} + +func tempLbbsDelete() { + out(fmt.Sprintf("%s記帳内容を削除しました%s
\n", + hconst.TagBigBegin, hconst.TagBigEnd)) +} + +func tempLbbsAdd() { + out(fmt.Sprintf("%s記帳を行いました%s
\n", + hconst.TagBigBegin, hconst.TagBigEnd)) +} + +func tempCommandDelete() { + out(fmt.Sprintf("%sコマンドを削除しました%s
\n", + hconst.TagBigBegin, hconst.TagBigEnd)) +} + +func tempCommandAdd() { + out(fmt.Sprintf("%sコマンドを登録しました%s
\n", + hconst.TagBigBegin, hconst.TagBigEnd)) +} + +func tempComment() { + out(fmt.Sprintf("%sコメントを更新しました%s
\n", + hconst.TagBigBegin, hconst.TagBigEnd)) +} diff --git a/go/internal/hako/top/top.go b/go/internal/hako/top/top.go new file mode 100644 index 0000000..cd878c3 --- /dev/null +++ b/go/internal/hako/top/top.go @@ -0,0 +1,427 @@ +// Package top provides top page display functions for the Hakoniwa game. +// This file is translated from Perl lib/Hako/Top.pm +// +// Ref: perl/lib/Hako/Top.pm +package top + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/neguse/hakoniwa/internal/hako/core" + "github.com/neguse/hakoniwa/internal/hako/hconst" + "github.com/neguse/hakoniwa/internal/hako/variable" +) + +// out outputs a string to the output buffer +func out(s string) { + variable.OutputBuffer.WriteString(s) +} + +// TopPageMain displays the top page +// Ref: perl/lib/Hako/Top.pm:32 +func TopPageMain() { + // Release lock + core.Unlock() + + // Output template + tempTopPage() +} + +// tempTopPage outputs the top page HTML +// Ref: perl/lib/Hako/Top.pm:42 +func tempTopPage() { + // Title + out(fmt.Sprintf("%s%s%s\n", hconst.TagTitleBegin, hconst.Title, hconst.TagTitleEnd)) + + // Debug mode turn button + if hconst.Debug { + out(fmt.Sprintf(`
+ +
+`, hconst.ThisFile)) + } + + // Money column header (optional based on hide mode) + mStr1 := "" + if hconst.HideMoneyMode != 0 { + mStr1 = fmt.Sprintf("%s資金%s", + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd) + } + + // Island list for select + islandList := getIslandList(variable.DefaultID) + + // Main form and table header + out(fmt.Sprintf(`

%sターン%d%s

+ +
+

%s自分の島へ%s

+
+あなたの島の名前は?
+
+ +パスワードをどうぞ!!
+
+
+
+ +
+ +

%s諸島の状況%s

+

+島の名前をクリックすると、観光することができます。 +

+ + + + + + +%s + + + + + +`, + hconst.TagHeaderBegin, variable.IslandTurn, hconst.TagHeaderEnd, + hconst.TagHeaderBegin, hconst.TagHeaderEnd, + hconst.ThisFile, + islandList, + variable.DefaultPassword, + hconst.TagHeaderBegin, hconst.TagHeaderEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + mStr1, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + hconst.BgTitleCell, hconst.TagTHBegin, hconst.TagTHEnd, + )) + + // Island rows + for ii := 0; ii < variable.IslandNumber; ii++ { + j := ii + 1 + island := variable.Islands[ii] + + id := island.ID + farm := island.Farm + factory := island.Factory + mountain := island.Mountain + + farmStr := "保有せず" + if farm != 0 { + farmStr = fmt.Sprintf("%d0%s", farm, hconst.UnitPop) + } + factoryStr := "保有せず" + if factory != 0 { + factoryStr = fmt.Sprintf("%d0%s", factory, hconst.UnitPop) + } + mountainStr := "保有せず" + if mountain != 0 { + mountainStr = fmt.Sprintf("%d0%s", mountain, hconst.UnitPop) + } + + // Island name with absent counter + name := "" + if island.Absent == 0 { + name = fmt.Sprintf("%s%s島%s", hconst.TagNameBegin, island.Name, hconst.TagNameEnd) + } else { + name = fmt.Sprintf("%s%s島(%d)%s", hconst.TagName2Begin, island.Name, island.Absent, hconst.TagName2End) + } + + // Parse prize field: "flags,monsters,turns" + prize := parsePrize(island.Prize) + + // Money display + mStr1 := "" + if hconst.HideMoneyMode == 1 { + mStr1 = fmt.Sprintf("", + hconst.BgInfoCell, island.Money, hconst.UnitMoney) + } else if hconst.HideMoneyMode == 2 { + mTmp := core.AboutMoney(island.Money) + mStr1 = fmt.Sprintf("", + hconst.BgInfoCell, mTmp) + } + + // Output island row + out(fmt.Sprintf(` + + + + +%s + + + + + + + + +`, + hconst.BgNumberCell, hconst.TagNumberBegin, j, hconst.TagNumberEnd, + hconst.BgNameCell, + hconst.ThisFile, id, + name, + prize, + hconst.BgInfoCell, island.Pop, hconst.UnitPop, + hconst.BgInfoCell, island.Area, hconst.UnitArea, + mStr1, + hconst.BgInfoCell, island.Food, hconst.UnitFood, + hconst.BgInfoCell, farmStr, + hconst.BgInfoCell, factoryStr, + hconst.BgInfoCell, mountainStr, + hconst.BgCommentCell, hconst.TagTHBegin, hconst.TagTHEnd, island.Comment, + )) + } + + out("
%s順位%s%s島%s%s人口%s%s面積%s%s食料%s%s農場規模%s%s工場規模%s%s採掘場規模%s
%d%s%s
%s%d%s + + +%s + +
+%s +
+%d%s%d%s%d%s%s%s%s
%sコメント:%s%s
\n\n
\n") + + // New island section + out(fmt.Sprintf("

%s新しい島を探す%s

\n", hconst.TagHeaderBegin, hconst.TagHeaderEnd)) + + if variable.IslandNumber < hconst.MaxIsland { + out(fmt.Sprintf(`
+どんな名前をつける予定?
+
+パスワードは?
+
+念のためパスワードをもう一回
+
+ + +
+`, hconst.ThisFile)) + } else { + out(" 島の数が最大数です・・・現在登録できません。\n") + } + + // Change name/password section + out(fmt.Sprintf(`
+

%s島の名前とパスワードの変更%s

+

+(注意)名前の変更には%d%sかかります。 +

+
+どの島ですか?
+ +
+どんな名前に変えますか?(変更する場合のみ)
+
+パスワードは?(必須)
+
+新しいパスワードは?(変更する時のみ)
+
+念のためパスワードをもう一回(変更する時のみ)
+
+ + +
+ +
+ +

%s最近の出来事%s

+`, + hconst.TagHeaderBegin, hconst.TagHeaderEnd, + hconst.CostChangeName, hconst.UnitMoney, + hconst.ThisFile, + islandList, + hconst.TagHeaderBegin, hconst.TagHeaderEnd, + )) + + // Recent events log + logPrintTop() + + // Discovery history + out(fmt.Sprintf("

%s発見の記録%s

\n", hconst.TagHeaderBegin, hconst.TagHeaderEnd)) + historyPrint() +} + +// parsePrize parses the prize field and returns HTML for prize display +// Prize format: "flags,monsters,turns" +// Ref: perl/lib/Hako/Top.pm:124-163 +func parsePrize(prizeField int) string { + // Convert to string for parsing + prizeStr := fmt.Sprintf("%d", prizeField) + + // Parse: flags,monsters,turns + parts := strings.Split(prizeStr, ",") + flags := 0 + monsters := 0 + turns := "" + + if len(parts) >= 1 { + flags, _ = strconv.Atoi(parts[0]) + } + if len(parts) >= 2 { + monsters, _ = strconv.Atoi(parts[1]) + } + if len(parts) >= 3 { + turns = parts[2] + } + + prize := "" + + // Turn prizes + turnParts := strings.Split(turns, ",") + for _, tp := range turnParts { + if tp != "" { + prize += fmt.Sprintf(`%s%s `, + tp, hconst.Prize[0]) + } + } + + // Flag prizes (prizes 1-9) + f := 1 + for i := 1; i < 10; i++ { + if (flags & f) != 0 { + prize += fmt.Sprintf(`%s `, + i, hconst.Prize[i]) + } + f *= 2 + } + + // Monster prizes + f = 1 + max := -1 + mNameList := "" + for i := 0; i < hconst.MonsterNumber; i++ { + if (monsters & f) != 0 { + mNameList += fmt.Sprintf("[%s] ", hconst.MonsterName[i]) + max = i + } + f *= 2 + } + if max != -1 { + prize += fmt.Sprintf(`%s `, + hconst.MonsterImage[max], mNameList) + } + + return prize +} + +// getIslandList generates island select options +// Ref: perl/lib/Hako/Main.pm:1079 +func getIslandList(selectID string) string { + list := "" + for i := 0; i < variable.IslandNumber; i++ { + name := variable.Islands[i].Name + id := variable.Islands[i].ID + selected := "" + if id == selectID { + selected = "SELECTED" + } + list += fmt.Sprintf("