Spaces:
Running
Running
Download js/game.js from HelloSun/empire-web: direct link, hf CLI and curl.
- Browser
- Download file 29.1 kB
-
https://huggingface.co/spaces/HelloSun/empire-web/resolve/main/js/game.js
- Command line
-
hf download hf://spaces/HelloSun/empire-web/js/game.js
-
curl -L -o game.js https://huggingface.co/spaces/HelloSun/empire-web/resolve/main/js/game.js
29.1 kB
| import { MAPS, BUILDINGS, TERRAIN, RESOURCES } from './data.js'; | |
| export const MAX_ACTIONS = 3; | |
| const SAVE_KEY = 'empire-web-save-v1'; | |
| const DEFAULT_MORALE = 80; | |
| const TERRAIN_BONUS = { plains: 1.0, forest: 1.2, mountain: 1.5, city: 1.3 }; | |
| const ACCEPT_RATES = { | |
| aggressive: [0.15, 0.20, 0.40], | |
| defensive: [0.50, 0.70, 0.50], | |
| mercantile: [0.40, 0.50, 0.80], | |
| diplomatic: [0.80, 0.85, 0.70], | |
| }; | |
| const TRADE_MARKUP = { aggressive: 1.5, defensive: 1.2, mercantile: 0.8, diplomatic: 1.0 }; | |
| const clone = (x) => JSON.parse(JSON.stringify(x)); | |
| const clamp0 = (n) => Math.max(0, n); | |
| export class Game { | |
| constructor() { | |
| this.state = null; | |
| this._handlers = new Set(); | |
| this._id = 0; | |
| } | |
| on(fn) { this._handlers.add(fn); return () => this._handlers.delete(fn); } | |
| emit(type = 'change', payload = null) { | |
| for (const fn of this._handlers) fn(this.state, type, payload); | |
| } | |
| commit(type = 'change', payload = null) { | |
| this.save(); | |
| this.emit(type, payload); | |
| } | |
| uid(prefix) { return `${prefix}_${Date.now().toString(36)}_${this._id++}`; } | |
| newGame({ mapId, factionId, identity }) { | |
| const map = MAPS[mapId] || MAPS.mainland; | |
| const territories = {}; | |
| for (const t of map.territories) { | |
| territories[t.id] = { ...clone(t), buildings: [] }; | |
| } | |
| const factions = {}; | |
| for (const f of map.factions) { | |
| const tpl = clone(f); | |
| factions[f.id] = { | |
| ...tpl, | |
| territories: tpl.territories.filter((id) => territories[id]), | |
| }; | |
| } | |
| for (const f of Object.values(factions)) { | |
| f.totalArmies = f.territories.reduce((s, id) => s + territories[id].armies, 0); | |
| } | |
| const faction = factions[factionId] || map.factions[0]; | |
| const ident = { | |
| leaderName: (identity?.leaderName || '').trim() || '君主', | |
| nationName: (identity?.nationName || '').trim() || faction.name, | |
| slogan: (identity?.slogan || '').trim() || '榮耀就在前方!', | |
| }; | |
| this.state = { | |
| version: 1, | |
| turn: 1, | |
| mapId: map.id, | |
| actionsLeft: MAX_ACTIONS, | |
| playerFactionId: faction.id, | |
| identity: ident, | |
| territories, | |
| factions, | |
| diplomacy: [], | |
| log: [], | |
| isOver: false, | |
| winner: null, | |
| stats: { battlesWon: 0, battlesLost: 0, recruited: 0, captured: 0 }, | |
| startedAt: Date.now(), | |
| }; | |
| this.addLog(`${ident.leaderName} 登基,成為 ${ident.nationName} 的君主。格言:「${ident.slogan}」`, 'story'); | |
| this.commit('new', { mapId: map.id, factionId: faction.id }); | |
| return this.state; | |
| } | |
| loadFromStorage() { | |
| try { | |
| const raw = localStorage.getItem(SAVE_KEY); | |
| if (!raw) return null; | |
| const data = JSON.parse(raw); | |
| if (!data || !data.territories || !data.factions) return null; | |
| for (const t of Object.values(data.territories)) { | |
| if (!Array.isArray(t.buildings)) t.buildings = []; | |
| if (!t.resources) t.resources = { gold: 0, food: 0, wood: 0, stone: 0 }; | |
| } | |
| for (const f of Object.values(data.factions)) { | |
| f.territories = (f.territories || []).filter((id) => data.territories[id]); | |
| f.totalArmies = f.territories.reduce((s, id) => s + data.territories[id].armies, 0); | |
| } | |
| if (!Array.isArray(data.log)) data.log = []; | |
| if (!Array.isArray(data.diplomacy)) data.diplomacy = []; | |
| if (!data.stats) data.stats = { battlesWon: 0, battlesLost: 0, recruited: 0, captured: 0 }; | |
| if (typeof data.actionsLeft !== 'number') data.actionsLeft = MAX_ACTIONS; | |
| this.state = data; | |
| this.emit('load'); | |
| return data; | |
| } catch { return null; } | |
| } | |
| hasSave() { | |
| try { return !!localStorage.getItem(SAVE_KEY); } catch { return false; } | |
| } | |
| save() { | |
| if (!this.state) return; | |
| try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)); } catch {} | |
| } | |
| clearSave() { | |
| try { localStorage.removeItem(SAVE_KEY); } catch {} | |
| } | |
| get map() { return MAPS[this.state.mapId]; } | |
| get player() { return this.state.factions[this.state.playerFactionId]; } | |
| faction(id) { return this.state.factions[id] || null; } | |
| territory(id) { return this.state.territories[id] || null; } | |
| isPlayer(id) { return id === this.state.playerFactionId; } | |
| ownerName(id) { return id ? (this.faction(id)?.name || '未知') : '無主之地'; } | |
| ownedByPlayer(id) { return this.territory(id)?.owner === this.state.playerFactionId; } | |
| addLog(text, kind = 'info') { | |
| this.state.log.push({ turn: this.state.turn, text, kind }); | |
| if (this.state.log.length > 200) this.state.log = this.state.log.slice(-200); | |
| } | |
| syncArmies(fid) { | |
| const f = this.faction(fid); | |
| if (!f) return; | |
| f.totalArmies = this.factionTerritories(fid).reduce((sum, t) => sum + t.armies, 0); | |
| } | |
| playerTerritories() { | |
| return this.player.territories.map((id) => this.territory(id)).filter(Boolean); | |
| } | |
| factionTerritories(fid) { | |
| const f = this.faction(fid); | |
| return f ? f.territories.map((id) => this.territory(id)).filter(Boolean) : []; | |
| } | |
| income(fid = this.state.playerFactionId) { | |
| const out = { gold: 0, food: 0, wood: 0, stone: 0 }; | |
| for (const t of this.factionTerritories(fid)) { | |
| out.gold += t.resources.gold + (t.buildings.includes('market') ? 2 : 0); | |
| out.food += t.resources.food; | |
| out.wood += t.resources.wood; | |
| out.stone += t.resources.stone; | |
| } | |
| return out; | |
| } | |
| upkeep(fid = this.state.playerFactionId) { | |
| const units = this.factionTerritories(fid).reduce((s, t) => s + t.armies, 0); | |
| return { gold: 0, food: units, wood: 0, stone: 0 }; | |
| } | |
| recruitCost(tid) { | |
| const t = this.territory(tid); | |
| const gold = t?.buildings.includes('barracks') ? 2 : 3; | |
| return { gold, food: 2 }; | |
| } | |
| maxRecruit(tid) { | |
| const t = this.territory(tid); | |
| if (!t) return 0; | |
| const cost = this.recruitCost(tid); | |
| return Math.max(0, Math.min( | |
| Math.floor(this.player.gold / cost.gold), | |
| Math.floor(this.player.food / cost.food), | |
| )); | |
| } | |
| canAfford(cost) { | |
| return (this.player.gold >= (cost.gold || 0)) | |
| && (this.player.food >= (cost.food || 0)) | |
| && (this.player.wood >= (cost.wood || 0)) | |
| && (this.player.stone >= (cost.stone || 0)); | |
| } | |
| spend(cost) { | |
| for (const k of ['gold', 'food', 'wood', 'stone']) { | |
| if (cost[k]) this.player[k] = clamp0(this.player[k] - cost[k]); | |
| } | |
| } | |
| spendAction() { | |
| this.state.actionsLeft = Math.max(0, this.state.actionsLeft - 1); | |
| } | |
| get outOfActions() { return this.state.actionsLeft <= 0; } | |
| // ── Player orders ────────────────────────────────────────────────────────── | |
| recruit(tid, n) { | |
| const t = this.territory(tid); | |
| if (!t) return { ok: false, msg: '找不到該疆域。' }; | |
| if (t.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${t.name}。` }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| n = Math.floor(n); | |
| if (!n || n < 1) return { ok: false, msg: '至少徵募 1 名士兵。' }; | |
| const cost = this.recruitCost(tid); | |
| if (!this.canAfford({ gold: cost.gold * n, food: cost.food * n })) { | |
| return { ok: false, msg: `資源不足:需要 ${cost.gold * n} 金幣與 ${cost.food * n} 糧食。` }; | |
| } | |
| this.spend({ gold: cost.gold * n, food: cost.food * n }); | |
| t.armies += n; | |
| this.player.totalArmies += n; | |
| this.state.stats.recruited += n; | |
| this.spendAction(); | |
| this.addLog(`在 ${t.name} 徵募了 ${n} 名士兵。`, 'player'); | |
| this.commit('recruit', { tid, n }); | |
| return { ok: true, n, tid, cost, left: this.state.actionsLeft }; | |
| } | |
| move(fromId, toId, n) { | |
| const from = this.territory(fromId); | |
| const to = this.territory(toId); | |
| if (!from || !to) return { ok: false, msg: '找不到該疆域。' }; | |
| if (from.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${from.name}。` }; | |
| if (to.owner !== this.state.playerFactionId) return { ok: false, msg: `你並未控制 ${to.name}。` }; | |
| if (!from.adjacentTo.includes(toId)) return { ok: false, msg: `${to.name} 與 ${from.name} 並不相鄰。` }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| const movable = from.armies - 2; | |
| if (movable <= 0) return { ok: false, msg: `${from.name} 至少需留守 2 名士兵。` }; | |
| n = n == null ? movable : Math.floor(n); | |
| n = Math.min(n, movable); | |
| if (n < 1) return { ok: false, msg: '沒有可調動的士兵。' }; | |
| from.armies -= n; | |
| to.armies += n; | |
| this.spendAction(); | |
| this.addLog(`從 ${from.name} 調動 ${n} 名士兵前往 ${to.name}。`, 'player'); | |
| this.commit('move', { fromId, toId, n }); | |
| return { ok: true, n, fromId, toId, left: this.state.actionsLeft }; | |
| } | |
| resolveCombat(attackerUnits, defenderUnits, terrain, defenderTerritory) { | |
| const terrainBonus = TERRAIN_BONUS[terrain] ?? 1; | |
| const wallBonus = defenderTerritory?.buildings.includes('walls') ? 0.3 : 0; | |
| const totalDefBonus = terrainBonus + wallBonus; | |
| const attackPower = attackerUnits * (DEFAULT_MORALE / 100); | |
| const defensePower = defenderUnits * (DEFAULT_MORALE / 100) * totalDefBonus; | |
| const ratio = attackPower / (defensePower || 1); | |
| const log = []; | |
| log.push(`進攻戰力 ${attackPower.toFixed(1)},防守戰力 ${defensePower.toFixed(1)}。`); | |
| log.push(`${TERRAIN[terrain]?.label || terrain}地形提供 ×${terrainBonus} 防禦。`); | |
| if (wallBonus > 0) log.push(`城牆額外提供 +${wallBonus} 防禦。`); | |
| let outcome; let attackerCasualties; let defenderCasualties; let captured = false; | |
| if (defenderUnits <= 0) { | |
| outcome = 'uncontested'; | |
| attackerCasualties = 0; | |
| defenderCasualties = 0; | |
| captured = true; | |
| log.push('前方沒有守軍阻擋。'); | |
| } else if (ratio >= 2.0) { | |
| outcome = 'decisive_victory'; | |
| attackerCasualties = Math.floor(attackerUnits * 0.1); | |
| defenderCasualties = defenderUnits; | |
| captured = true; | |
| log.push('壓倒性勝利!守軍全面潰散。'); | |
| } else if (ratio >= 1.2) { | |
| outcome = 'victory'; | |
| attackerCasualties = Math.floor(attackerUnits * 0.25); | |
| defenderCasualties = Math.floor(defenderUnits * 0.6); | |
| captured = true; | |
| log.push('勝利!疆域已落入你手。'); | |
| } else if (ratio >= 0.8) { | |
| outcome = 'pyrrhic_victory'; | |
| attackerCasualties = Math.floor(attackerUnits * 0.5); | |
| defenderCasualties = Math.floor(defenderUnits * 0.4); | |
| captured = ratio >= 1.0; | |
| log.push(captured ? '慘勝——戰場屬於你,但代價沉重。' : '血戰僵持,進攻方被迫撤退。'); | |
| } else { | |
| outcome = 'defeat'; | |
| attackerCasualties = Math.floor(attackerUnits * 0.6); | |
| defenderCasualties = Math.floor(defenderUnits * 0.15); | |
| captured = false; | |
| log.push('戰敗!攻勢在守軍面前崩潰。'); | |
| } | |
| log.push(`我軍傷亡 ${attackerCasualties},敵軍傷亡 ${defenderCasualties}。`); | |
| return { outcome, attackerCasualties, defenderCasualties, captured, ratio, log }; | |
| } | |
| attack(fromId, toId, n) { | |
| const from = this.territory(fromId); | |
| const to = this.territory(toId); | |
| if (!from || !to) return { ok: false, msg: '找不到該疆域。' }; | |
| if (from.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${from.name}。` }; | |
| if (to.owner === this.state.playerFactionId) return { ok: false, msg: `你已掌握 ${to.name}。` }; | |
| if (!from.adjacentTo.includes(toId)) return { ok: false, msg: `${to.name} 與 ${from.name} 並不相鄰。` }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| if (from.armies < 1) return { ok: false, msg: `${from.name} 已無駐軍。` }; | |
| n = n == null ? from.armies : Math.floor(n); | |
| n = Math.min(Math.max(1, n), from.armies); | |
| if (n < 1) return { ok: false, msg: '至少派出 1 名士兵。' }; | |
| const defenderFactionId = to.owner; | |
| const defenderName = this.ownerName(defenderFactionId); | |
| const preDef = to.armies; | |
| const result = this.resolveCombat(n, preDef, to.type, to); | |
| const sent = n; | |
| const survivors = Math.max(0, sent - result.attackerCasualties); | |
| from.armies = Math.max(0, from.armies - sent); | |
| this.player.totalArmies = Math.max(0, this.player.totalArmies - result.attackerCasualties); | |
| let eliminated = null; | |
| if (result.captured) { | |
| if (defenderFactionId) { | |
| const old = this.faction(defenderFactionId); | |
| if (old) { | |
| to.armies = Math.max(0, to.armies - result.defenderCasualties); | |
| old.territories = old.territories.filter((id) => id !== toId); | |
| old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties); | |
| if (old.territories.length === 0) { | |
| eliminated = old; | |
| this.addLog(`${old.name} 已從地圖上滅亡。`, 'critical'); | |
| } | |
| } | |
| } | |
| to.owner = this.state.playerFactionId; | |
| to.armies = survivors; | |
| this.player.territories.push(toId); | |
| this.state.stats.battlesWon += 1; | |
| this.state.stats.captured += 1; | |
| this.addLog(`你從 ${defenderName} 手中奪下 ${to.name},${survivors} 名士兵倖存。`, 'victory'); | |
| } else { | |
| to.armies = Math.max(0, to.armies - result.defenderCasualties); | |
| if (defenderFactionId) { | |
| const old = this.faction(defenderFactionId); | |
| if (old) old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties); | |
| } | |
| this.state.stats.battlesLost += 1; | |
| this.addLog(`對 ${to.name} 的攻勢失敗,損失 ${result.attackerCasualties} 名士兵。`, 'defeat'); | |
| } | |
| this.spendAction(); | |
| this.syncArmies(this.state.playerFactionId); | |
| const payload = { | |
| fromId, toId, sent, defenderUnits: preDef, defenderName, | |
| result, survivors, eliminated, | |
| }; | |
| const win = this.checkWinner(); | |
| if (win) this.finish(win); | |
| this.commit('battle', payload); | |
| return { ok: true, ...payload, win, left: this.state.actionsLeft }; | |
| } | |
| build(tid, type) { | |
| const t = this.territory(tid); | |
| const def = BUILDINGS[type]; | |
| if (!t || !def) return { ok: false, msg: '未知的建築。' }; | |
| if (t.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${t.name}。` }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| if (t.buildings.includes(type)) return { ok: false, msg: `${t.name} 已建有${def.label}。` }; | |
| if (!this.canAfford(def.cost)) { | |
| const need = Object.entries(def.cost).map(([k, v]) => `${v} ${RESOURCES[k].label}`).join('、'); | |
| return { ok: false, msg: `資源不足:需要 ${need}。` }; | |
| } | |
| this.spend(def.cost); | |
| t.buildings.push(type); | |
| this.spendAction(); | |
| this.addLog(`在 ${t.name} 建成${def.label}。`, 'player'); | |
| this.commit('build', { tid, type }); | |
| return { ok: true, tid, type, left: this.state.actionsLeft }; | |
| } | |
| // ── Diplomacy ───────────────────────────────────────────────────────────── | |
| relation(a, b) { | |
| const [fa, fb] = a < b ? [a, b] : [b, a]; | |
| return this.state.diplomacy.find((r) => r.factionA === fa && r.factionB === fb) || null; | |
| } | |
| relationStatus(a, b) { return this.relation(a, b)?.status || 'neutral'; } | |
| setRelation(a, b, status, turns, initiatedBy) { | |
| const [fa, fb] = a < b ? [a, b] : [b, a]; | |
| const idx = this.state.diplomacy.findIndex((r) => r.factionA === fa && r.factionB === fb); | |
| const rel = { factionA: fa, factionB: fb, status, turnsRemaining: turns, initiatedBy }; | |
| if (idx >= 0) this.state.diplomacy[idx] = rel; else this.state.diplomacy.push(rel); | |
| } | |
| otherFaction(fid) { | |
| const f = this.faction(fid); | |
| if (!f || f.id === this.state.playerFactionId) return null; | |
| return f; | |
| } | |
| proposeAlliance(fid) { | |
| const f = this.otherFaction(fid); | |
| if (!f) return { ok: false, msg: '無效的勢力。' }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| const status = this.relationStatus(this.state.playerFactionId, fid); | |
| if (status === 'allied') return { ok: false, msg: '你們已是同盟。' }; | |
| let chance = (ACCEPT_RATES[f.personality] || [0.5])[0]; | |
| if (f.territories.length <= 1) chance += 0.3; | |
| this.spendAction(); | |
| if (Math.random() < chance) { | |
| this.setRelation(this.state.playerFactionId, fid, 'allied', -1, this.state.playerFactionId); | |
| this.addLog(`${f.name} 接受你的同盟提案。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: true, msg: `${f.name} 接受你的同盟提案。` }; | |
| } | |
| this.addLog(`${f.name} 拒絕你的同盟提案。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: false, msg: `${f.name} 拒絕了你的提案。` }; | |
| } | |
| proposePeace(fid) { | |
| const f = this.otherFaction(fid); | |
| if (!f) return { ok: false, msg: '無效的勢力。' }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| const status = this.relationStatus(this.state.playerFactionId, fid); | |
| if (status === 'peace') return { ok: false, msg: '你們已處於停戰狀態。' }; | |
| if (status === 'allied') return { ok: false, msg: '同盟關係已約束雙方。' }; | |
| let chance = (ACCEPT_RATES[f.personality] || [0.5, 0.5])[1]; | |
| if (f.territories.length <= 1) chance += 0.3; | |
| const turns = 3 + Math.floor(Math.random() * 3); | |
| this.spendAction(); | |
| if (Math.random() < chance) { | |
| this.setRelation(this.state.playerFactionId, fid, 'peace', turns, this.state.playerFactionId); | |
| this.addLog(`與 ${f.name} 達成 ${turns} 回合停戰。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: true, turns, msg: `${f.name} 同意停戰 ${turns} 回合。` }; | |
| } | |
| this.addLog(`${f.name} 拒絕停戰。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: false, msg: `${f.name} 拒絕停戰。` }; | |
| } | |
| proposeTrade(fid, giveRes, giveAmount, getRes) { | |
| const f = this.otherFaction(fid); | |
| if (!f) return { ok: false, msg: '無效的勢力。' }; | |
| if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' }; | |
| if (!['gold', 'food', 'wood', 'stone'].includes(giveRes) || !['gold', 'food', 'wood', 'stone'].includes(getRes)) { | |
| return { ok: false, msg: '無效的資源。' }; | |
| } | |
| if (giveRes === getRes) return { ok: false, msg: '請選擇兩種不同的資源。' }; | |
| giveAmount = Math.floor(giveAmount); | |
| if (!giveAmount || giveAmount < 1) return { ok: false, msg: '至少提供 1 單位資源。' }; | |
| if (this.player[giveRes] < giveAmount) return { ok: false, msg: `你只有 ${this.player[giveRes]} ${RESOURCES[giveRes].label}。` }; | |
| const chance = (ACCEPT_RATES[f.personality] || [0.5, 0.5, 0.5])[2]; | |
| const markup = TRADE_MARKUP[f.personality] || 1; | |
| const getAmount = Math.max(1, Math.round(giveAmount / markup)); | |
| if (f[getRes] < getAmount) return { ok: false, msg: `${f.name} 沒有足夠的${RESOURCES[getRes].label}。` }; | |
| this.spendAction(); | |
| if (Math.random() < chance) { | |
| this.player[giveRes] -= giveAmount; | |
| this.player[getRes] += getAmount; | |
| f[giveRes] += giveAmount; | |
| f[getRes] -= getAmount; | |
| this.addLog(`以 ${giveAmount} ${RESOURCES[giveRes].label} 向 ${f.name} 換得 ${getAmount} ${RESOURCES[getRes].label}。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: true, getAmount, msg: `${f.name} 接受交易:以 ${giveAmount} ${RESOURCES[giveRes].label} 換取 ${getAmount} ${RESOURCES[getRes].label}。` }; | |
| } | |
| this.addLog(`${f.name} 拒絕你的貿易提案。`, 'diplomacy'); | |
| this.commit('diplomacy'); | |
| return { ok: true, accepted: false, getAmount: 0, msg: `${f.name} 拒絕了這筆交易。` }; | |
| } | |
| getRelations() { | |
| return this.state.diplomacy.filter((r) => r.factionA === this.state.playerFactionId || r.factionB === this.state.playerFactionId); | |
| } | |
| relationWith(fid) { return this.relation(this.state.playerFactionId, fid); } | |
| // ── Turn cycle ──────────────────────────────────────────────────────────── | |
| endTurn() { | |
| if (this.state.isOver) return null; | |
| const report = { turn: this.state.turn, income: null, upkeep: null, net: null, aiLogs: [], diploLogs: [], starvation: [], playerBefore: null }; | |
| const player = this.player; | |
| // income & upkeep for every faction | |
| for (const f of Object.values(this.state.factions)) { | |
| if (f.territories.length === 0) continue; | |
| const inc = this.income(f.id); | |
| const up = this.upkeep(f.id); | |
| f.gold += inc.gold - up.gold; | |
| f.food += inc.food - up.food; | |
| f.wood += inc.wood - up.wood; | |
| f.stone += inc.stone - up.stone; | |
| if (f.food < 0) { | |
| f.food = 0; | |
| if (f.id === this.state.playerFactionId) report.starvation.push('糧倉已空,軍隊吃掉了最後的存糧。'); | |
| } | |
| if (f.gold < 0) f.gold = 0; | |
| if (f.id === this.state.playerFactionId) { | |
| report.income = inc; | |
| report.upkeep = up; | |
| report.net = { gold: inc.gold - up.gold, food: inc.food - up.food, wood: inc.wood, stone: inc.stone }; | |
| report.playerBefore = { gold: player.gold, food: player.food }; | |
| } | |
| } | |
| this.addLog(`第 ${this.state.turn} 回合結束。國庫收取 ${report.income.gold} 金幣、${report.income.food} 糧食、${report.income.wood} 木材、${report.income.stone} 石料。`, 'turn'); | |
| // AI factions act | |
| for (const f of Object.values(this.state.factions)) { | |
| if (f.id === this.state.playerFactionId) continue; | |
| if (f.territories.length === 0) continue; | |
| const logs = this.runAI(f); | |
| report.aiLogs.push(...logs); | |
| } | |
| // diplomacy countdown | |
| for (let i = this.state.diplomacy.length - 1; i >= 0; i--) { | |
| const rel = this.state.diplomacy[i]; | |
| if (rel.status === 'peace' && rel.turnsRemaining > 0) { | |
| rel.turnsRemaining -= 1; | |
| if (rel.turnsRemaining <= 0) { | |
| const a = this.faction(rel.factionA)?.name || rel.factionA; | |
| const b = this.faction(rel.factionB)?.name || rel.factionB; | |
| this.state.diplomacy.splice(i, 1); | |
| report.diploLogs.push(`${a} 與 ${b} 的停戰協議已到期。`); | |
| } | |
| } | |
| } | |
| report.aiLogs.forEach((l) => this.addLog(l, 'ai')); | |
| report.diploLogs.forEach((l) => this.addLog(l, 'diplomacy')); | |
| // player elimination | |
| if (player.territories.length === 0) { | |
| const winner = Object.values(this.state.factions).find((f) => f.territories.length > 0); | |
| this.finish(winner?.id || null); | |
| this.commit('turn', report); | |
| return report; | |
| } | |
| this.state.turn += 1; | |
| this.state.actionsLeft = MAX_ACTIONS; | |
| const win = this.checkWinner(); | |
| if (win) this.finish(win); | |
| for (const f of Object.values(this.state.factions)) this.syncArmies(f.id); | |
| this.commit('turn', report); | |
| return report; | |
| } | |
| runAI(faction) { | |
| const logs = []; | |
| const squadTerr = this.factionTerritories(faction.id)[0]; | |
| if (!squadTerr) return logs; | |
| // recruit | |
| if (faction.gold >= 6 && faction.food >= 4) { | |
| const n = Math.min(Math.floor(faction.gold / 3), Math.floor(faction.food / 2), 3); | |
| if (n > 0) { | |
| faction.gold -= n * 3; | |
| faction.food -= n * 2; | |
| squadTerr.armies += n; | |
| faction.totalArmies += n; | |
| logs.push(`${faction.name} 在 ${squadTerr.name} 徵募 ${n} 名士兵。`); | |
| } | |
| } | |
| // desperate alliances break | |
| if (faction.territories.length <= 1 && faction.gold < 5 && faction.personality === 'aggressive') { | |
| const rels = this.state.diplomacy.filter((r) => (r.factionA === faction.id || r.factionB === faction.id) && r.status === 'allied'); | |
| for (const r of rels) { | |
| this.state.diplomacy = this.state.diplomacy.filter((x) => x !== r); | |
| logs.push(`${faction.name} 在絕境中撕毀同盟。`); | |
| } | |
| } | |
| // attack one adjacent target | |
| for (const tid of [...faction.territories]) { | |
| const src = this.territory(tid); | |
| if (!src || src.armies < 2) continue; | |
| for (const adjId of src.adjacentTo) { | |
| const target = this.territory(adjId); | |
| if (!target || target.owner === faction.id) continue; | |
| if (target.owner) { | |
| const status = this.relationStatus(faction.id, target.owner); | |
| if (status === 'allied' || status === 'peace') continue; | |
| } | |
| if (src.armies < target.armies && faction.personality !== 'aggressive') continue; | |
| const pre = src.armies; | |
| const result = this.resolveCombat(pre, target.armies, target.type, target); | |
| src.armies = Math.max(0, pre - result.attackerCasualties); | |
| faction.totalArmies = Math.max(0, faction.totalArmies - result.attackerCasualties); | |
| logs.push(`${faction.name} 自 ${src.name} 進攻 ${target.name}(${pre} 對 ${target.armies})。`); | |
| if (result.captured) { | |
| const oldOwnerId = target.owner; | |
| if (oldOwnerId) { | |
| const old = this.faction(oldOwnerId); | |
| target.armies = Math.max(0, target.armies - result.defenderCasualties); | |
| if (old) { | |
| old.territories = old.territories.filter((id) => id !== adjId); | |
| old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties); | |
| if (old.territories.length === 0) { | |
| logs.push(`${old.name} 已從地圖上滅亡。`); | |
| if (old.id === this.state.playerFactionId) { | |
| target.owner = faction.id; | |
| target.armies = src.armies; | |
| src.armies = 0; | |
| faction.territories.push(adjId); | |
| this.addLog(`${faction.name} 占領了 ${target.name}。`, 'critical'); | |
| return logs; | |
| } | |
| } | |
| } | |
| } | |
| const survivors = src.armies; | |
| src.armies = 0; | |
| target.owner = faction.id; | |
| target.armies = survivors; | |
| faction.territories.push(adjId); | |
| logs.push(`${faction.name} 以 ${survivors} 名倖存者占領 ${target.name}。`); | |
| } else { | |
| target.armies = Math.max(0, target.armies - result.defenderCasualties); | |
| if (target.owner) { | |
| const old = this.faction(target.owner); | |
| if (old) old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties); | |
| } | |
| logs.push(`${faction.name} 對 ${target.name} 的攻勢被擊退。`); | |
| } | |
| return logs; | |
| } | |
| } | |
| return logs; | |
| } | |
| checkWinner() { | |
| const total = Object.keys(this.state.territories).length; | |
| for (const f of Object.values(this.state.factions)) { | |
| if (f.territories.length >= total) return f.id; | |
| if (f.territories.length > 0) { | |
| const owned = Object.values(this.state.territories).filter((t) => t.owner === f.id).length; | |
| if (owned >= total) return f.id; | |
| } | |
| } | |
| return null; | |
| } | |
| finish(winnerId) { | |
| if (this.state.isOver) return; | |
| this.state.isOver = true; | |
| this.state.winner = winnerId; | |
| const name = winnerId ? this.faction(winnerId)?.name : null; | |
| this.addLog(winnerId === this.state.playerFactionId | |
| ? `勝利!${name} 統治了世界。` | |
| : `${name || '某個對手'} 征服了世界。`, 'critical'); | |
| } | |
| // forecast for attack modal | |
| forecast(fromId, toId, n) { | |
| const from = this.territory(fromId); | |
| const to = this.territory(toId); | |
| if (!from || !to) return null; | |
| const units = n == null ? from.armies : n; | |
| const terrainBonus = TERRAIN_BONUS[to.type] ?? 1; | |
| const wallBonus = to.buildings.includes('walls') ? 0.3 : 0; | |
| const atk = units * 0.8; | |
| const def = to.armies * 0.8 * (terrainBonus + wallBonus); | |
| const ratio = atk / (def || 1); | |
| let label = '絕望'; | |
| let tone = 'bad'; | |
| if (to.armies <= 0 || ratio >= 2) { label = '壓倒性'; tone = 'great'; } | |
| else if (ratio >= 1.2) { label = '有利'; tone = 'good'; } | |
| else if (ratio >= 1.0) { label = '勢均力敵'; tone = 'warn'; } | |
| else if (ratio >= 0.8) { label = '冒險'; tone = 'warn'; } | |
| else { label = '恐將失敗'; tone = 'bad'; } | |
| return { ratio, label, tone, atk, def, terrainBonus, wallBonus }; | |
| } | |
| factionStanding(fid) { | |
| const f = this.faction(fid); | |
| if (!f) return null; | |
| return { | |
| territories: f.territories.length, | |
| armies: this.factionTerritories(fid).reduce((s, t) => s + t.armies, 0), | |
| personality: f.personality, | |
| color: f.color, | |
| }; | |
| } | |
| } | |