/** * 주사위 땅따먹기 computer player: buys and builds while it keeps a cash reserve against the tolls ahead, sells the * cheapest thing that covers a debt, escapes the island early in the game, flies to the best square on 우주여행, * never proposes trades and only accepts clearly good ones. */ import type { BotStrategy } from '../bots/types'; import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; import { GROUPS, SPACES, investment, type Level } from './board'; import type { MarbleAction, MarbleView } from './index'; type Act = Extract; export const marbleBot: BotStrategy = ({ me, view, legal, random }) => { const req = answerRequests(legal); if (req) return req; const find = (t: T) => legal.find((a) => a.type === t) as Act | undefined; const meP = view.players.find((p) => p.id === me)!; const cash = meP.cash; const owner = new Map(view.owned.map((o) => [o.pos, o])); const theirs = view.owned.filter((o) => o.owner !== me); // Keep enough cash for a bad landing in the next lap; less as the round limit approaches // (then cash and land count the same for the final ranking). const maxToll = Math.max(0, ...theirs.map((o) => o.toll)); let reserve = Math.min(150, Math.max(20, 0.6 * maxToll)); if (view.maxRounds > 0) reserve *= Math.min(1, (view.maxRounds - view.round + 1) / 5); /** Owning the rest of a colour group makes this square worth more (and can win with 독점). */ const groupBonus = (pos: number) => { const g = SPACES[pos]!.group; if (!g) return 0; const mine = GROUPS[g].filter((p) => owner.get(p)?.owner === me).length; return mine * (view.options.monopolyWin ? 20 : 8); }; // Trade offered to me: accept only when I get clearly more than I give (book value). const respond = legal.filter((a) => a.type === 'TRADE_RESPOND') as Act<'TRADE_RESPOND'>[]; if (respond.length && view.trade) { const worth = (o: { lands: number[]; cash: number }) => o.cash + o.lands.reduce((s, p) => s + investment(p, (owner.get(p)?.level ?? 0) as Level), 0); const good = worth(view.trade.give) >= 1.3 * worth(view.trade.take) + 5 && cash + view.trade.give.cash - view.trade.take.cash >= 0; return respond.find((a) => a.accept === good) ?? respond[0]!; } // Debt: the smallest single sale that covers it, else the biggest sale (top stages before whole lands). const sells = legal.filter((a) => a.type === 'SELL') as Act<'SELL'>[]; if (sells.length && view.my?.sell) { const short = view.debt?.short ?? 0; const amount = (a: Act<'SELL'>) => view.my!.sell!.find((o) => o.pos === a.pos && o.what === a.what)?.amount ?? 0; const covers = sells.filter((a) => amount(a) >= short); if (covers.length) return best(covers, (a) => -amount(a) - groupBonus(a.pos) + (a.what === 'top' ? 1 : 0), random); return best(sells, (a) => amount(a) + (a.what === 'top' ? 100 : 0) - groupBonus(a.pos), random); } // Landed on a square: buy / build / take over while the reserve stays. const d = view.my?.decide; if (d) { const buy = find('BUY'); if (buy && d.buy !== null && cash - d.buy >= reserve - groupBonus(d.pos)) return buy; const build = find('BUILD'); if (build && d.build && cash - d.build.cost >= reserve) return build; const take = find('TAKEOVER'); if (take && d.takeover !== null && cash - d.takeover >= 1.5 * reserve + 20) return take; return find('PASS') ?? pick(playable(legal), random); } // 우주여행: pick the destination worth most. const space = legal.filter((a) => a.type === 'SPACE_TRAVEL') as Act<'SPACE_TRAVEL'>[]; if (space.length && view.spacePreview) { const prev = new Map(view.spacePreview.map((p) => [p.dest, p])); const score = (dest: number): number => { const p = prev.get(dest); const sp = SPACES[dest]!; let v = (p?.salary ? view.options.salary : 0) - (p?.toll ?? 0) * 1.5; const o = owner.get(dest); if (sp.price !== null && !o && cash - (p?.fare ?? 0) - sp.price >= reserve) v += 15 + sp.price / 2 + groupBonus(dest); if (o?.owner === me && sp.kind === 'city' && o.level < 3) v += 8; if (sp.kind === 'fundIn') v += view.fund; if (sp.kind === 'island') v -= 30; if (sp.kind === 'fundOut') v -= 15; return v + random(); }; return best(space, (a) => score(a.dest), random); } // Island: use an escape card, or pay the fee while the game is young and cash is plentiful. if (view.my?.island) { const card = find('USE_ESCAPE_CARD'); if (card) return card; const fee = find('PAY_ISLAND_FEE'); const early = view.maxRounds === 0 || view.round <= view.maxRounds / 2; if (fee && early && cash - (view.my.island.fee ?? 0) >= 2 * reserve) return fee; } // Never start trades: roll. const rest = playable(legal).filter((a) => typeOf(a) !== 'PROPOSE_TRADE'); return find('ROLL') ?? pick(rest.length ? rest : legal, random); };